feat(base): production, modules, adjacency auras, compounds, and crew speed
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
07eb8aa22e
commit
7a0f997f70
40 changed files with 147944 additions and 35 deletions
|
|
@ -9,6 +9,12 @@ import {
|
|||
setLaborHeadcount as setLaborHeadcountAssignment,
|
||||
upgradeStructure as upgradeStructureService,
|
||||
} from "@/lib/base";
|
||||
import {
|
||||
installModule as installModuleService,
|
||||
uninstallModule as uninstallModuleService,
|
||||
} from "@/lib/base/modules";
|
||||
import { emitGameEvent } from "@/utils/event-log/emit";
|
||||
import { EventTypes } from "@/utils/event-log/eventTypes";
|
||||
import { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQualification";
|
||||
import { hasPermission } from "@/utils/access-control/hasPermission";
|
||||
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
|
||||
|
|
@ -81,6 +87,72 @@ export async function fireStaff(staffingId: number): Promise<ActionResult<null>>
|
|||
}
|
||||
}
|
||||
|
||||
export async function toggleProduction(
|
||||
gameStructureId: number,
|
||||
paused: boolean,
|
||||
): Promise<ActionResult<{ paused: boolean }>> {
|
||||
try {
|
||||
const { payload, user } = await authenticate();
|
||||
if (!(await hasLogisticsQualification(payload, user))) {
|
||||
return { success: false, error: "Insufficient permissions." };
|
||||
}
|
||||
const structure = await payload.findByID({
|
||||
collection: "game-structures",
|
||||
id: gameStructureId,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
});
|
||||
await payload.update({
|
||||
collection: "game-structures",
|
||||
id: gameStructureId,
|
||||
data: { productionPaused: paused },
|
||||
overrideAccess: true,
|
||||
});
|
||||
await emitGameEvent(payload, {
|
||||
type: EventTypes.ProductionToggled,
|
||||
message: `${structure.name} production ${paused ? "paused" : "resumed"} by ${user.username ?? "logistics"}`,
|
||||
structure: gameStructureId,
|
||||
targetCollection: "game-structures",
|
||||
targetId: gameStructureId,
|
||||
});
|
||||
return { success: true, data: { paused } };
|
||||
} catch (error) {
|
||||
return actionError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function installModule(
|
||||
gameStructureId: number,
|
||||
moduleName: string,
|
||||
): Promise<ActionResult<{ installed: { name: string; installedAt: string | null }[] }>> {
|
||||
try {
|
||||
const { payload, user } = await authenticate();
|
||||
if (!(await hasLogisticsQualification(payload, user))) {
|
||||
return { success: false, error: "Insufficient permissions." };
|
||||
}
|
||||
const installed = await installModuleService(payload, gameStructureId, moduleName);
|
||||
return { success: true, data: { installed } };
|
||||
} catch (error) {
|
||||
return actionError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function uninstallModule(
|
||||
gameStructureId: number,
|
||||
moduleName: string,
|
||||
): Promise<ActionResult<{ installed: { name: string; installedAt: string | null }[] }>> {
|
||||
try {
|
||||
const { payload, user } = await authenticate();
|
||||
if (!(await hasLogisticsQualification(payload, user))) {
|
||||
return { success: false, error: "Insufficient permissions." };
|
||||
}
|
||||
const installed = await uninstallModuleService(payload, gameStructureId, moduleName);
|
||||
return { success: true, data: { installed } };
|
||||
} catch (error) {
|
||||
return actionError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function setLaborHeadcount(
|
||||
structureId: number,
|
||||
classificationId: number,
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ export async function placeStructure(params: {
|
|||
blueprintId: number;
|
||||
name: string;
|
||||
factionId?: number | null;
|
||||
parentStructureId?: number | null;
|
||||
x: number;
|
||||
y: number;
|
||||
}): Promise<ActionResult> {
|
||||
|
|
@ -101,6 +102,27 @@ export async function placeStructure(params: {
|
|||
}
|
||||
}
|
||||
|
||||
let parentStructureId: number | null = null;
|
||||
if (params.parentStructureId != null) {
|
||||
const hub = await payload
|
||||
.findByID({
|
||||
collection: "game-structures",
|
||||
id: params.parentStructureId,
|
||||
depth: 0,
|
||||
})
|
||||
.catch(() => null);
|
||||
if (!hub) {
|
||||
return { success: false, error: "Compound hub not found." };
|
||||
}
|
||||
if ((hub as unknown as GameStructure).map !== mapId) {
|
||||
return { success: false, error: "The compound hub is on a different map." };
|
||||
}
|
||||
if ((hub as unknown as GameStructure).parentStructure != null) {
|
||||
return { success: false, error: "Compound hubs cannot themselves be compound children." };
|
||||
}
|
||||
parentStructureId = hub.id;
|
||||
}
|
||||
|
||||
const [zonesRes, nodesRes, structuresRes] = await Promise.all([
|
||||
payload.find({
|
||||
collection: "map-zones",
|
||||
|
|
@ -184,6 +206,7 @@ export async function placeStructure(params: {
|
|||
map: mapId,
|
||||
coordinates: point,
|
||||
constructionStatus: "awaiting_materials",
|
||||
parentStructure: parentStructureId,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -240,7 +263,7 @@ export interface MapZoneInput {
|
|||
mapId: number;
|
||||
name?: string | null;
|
||||
description?: string | null;
|
||||
type: "water" | "land" | "territory" | "border";
|
||||
type: "water" | "land" | "territory" | "border" | "other";
|
||||
resourceIds?: number[];
|
||||
fillColor?: string | null;
|
||||
strokeColor?: string | null;
|
||||
|
|
|
|||
|
|
@ -1,12 +1,10 @@
|
|||
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 { calculateStoredMass, calculateUtilization, getGridSlots } from "@/lib/logistics";
|
||||
import type { LaborClassificationOption, StructureStaffingOption } from "@/lib/base";
|
||||
import { effectiveCrew } from "@/lib/base/constructionSpeed";
|
||||
import { installedModuleEffects } from "@/lib/base/modules";
|
||||
import { currencyConfigFromGameRules } from "@/lib/banking";
|
||||
import { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQualification";
|
||||
import { isSuperuser } from "@/utils/access-control/hasPermission";
|
||||
|
|
@ -22,11 +20,29 @@ export interface StructureDetailData {
|
|||
gridSlots: number | null;
|
||||
totalValue: number;
|
||||
constructionMaterials: { name: string; required: number; delivered: number }[];
|
||||
constructionCrew: { required: number; effective: number };
|
||||
};
|
||||
upgrade: {
|
||||
targetName: string;
|
||||
materials: { name: string; needed: number; have: number }[];
|
||||
} | null;
|
||||
production: {
|
||||
paused: boolean;
|
||||
stallReason: string | null;
|
||||
inputs: { name: string; amountPerHour: number }[];
|
||||
outputs: { name: string; amountPerHour: number }[];
|
||||
};
|
||||
modules: {
|
||||
slots: number;
|
||||
installed: { name: string; installedAt: string | null }[];
|
||||
options: {
|
||||
name: string;
|
||||
category: string;
|
||||
description: string | null;
|
||||
effects: { type: string; value: number }[];
|
||||
materials: { name: string; amount: number }[];
|
||||
}[];
|
||||
};
|
||||
staffing: {
|
||||
structureStaffing: StructureStaffingOption;
|
||||
staff: {
|
||||
|
|
@ -76,8 +92,16 @@ export async function fetchStructureDetail(
|
|||
const structureType = structure.type;
|
||||
const storedResources = structure.storedResources ?? [];
|
||||
const currentMass = calculateStoredMass(storedResources);
|
||||
const utilization = calculateUtilization(structureType, storedResources);
|
||||
const gridSlots = getGridSlots(structureType);
|
||||
const moduleEffects = installedModuleEffects(
|
||||
structureType,
|
||||
(structure.installedModules ?? []).map((entry) => entry.name),
|
||||
);
|
||||
const utilization = calculateUtilization(
|
||||
structureType,
|
||||
storedResources,
|
||||
moduleEffects.capacityMass,
|
||||
);
|
||||
const gridSlots = getGridSlots(structureType, moduleEffects.storageSlots);
|
||||
const description = structureType.description
|
||||
? convertLexicalToPlaintext({ data: structureType.description })
|
||||
: null;
|
||||
|
|
@ -194,6 +218,9 @@ export async function fetchStructureDetail(
|
|||
limit: 100,
|
||||
depth: 0,
|
||||
});
|
||||
const buildEfficiencyById = new Map(
|
||||
classificationsRes.docs.map((doc) => [doc.id, doc.buildEfficiency ?? 1]),
|
||||
);
|
||||
const classifications: LaborClassificationOption[] = classificationsRes.docs.map((doc) => ({
|
||||
id: doc.id,
|
||||
name: doc.name,
|
||||
|
|
@ -274,13 +301,77 @@ export async function fetchStructureDetail(
|
|||
};
|
||||
});
|
||||
|
||||
const constructionCrew = {
|
||||
required: structureType.constructionCrewRequired ?? 0,
|
||||
effective: effectiveCrew(
|
||||
staff
|
||||
.filter(
|
||||
(member) => member.positionType === "labor" && member.laborClassificationId !== null,
|
||||
)
|
||||
.map((member) => ({
|
||||
classificationId: member.laborClassificationId as number,
|
||||
headcount: member.headcount,
|
||||
efficiency: buildEfficiencyById.get(member.laborClassificationId as number) ?? 1,
|
||||
})),
|
||||
),
|
||||
};
|
||||
|
||||
const recipeLine = (line: {
|
||||
resource: number | { id: number; name: string };
|
||||
amountPerHour?: number | null;
|
||||
}): { name: string; amountPerHour: number } => {
|
||||
const resource = line.resource;
|
||||
const name =
|
||||
typeof resource === "object" && resource !== null ? resource.name : `Resource #${resource}`;
|
||||
return { name, amountPerHour: line.amountPerHour ?? 0 };
|
||||
};
|
||||
const production = {
|
||||
paused: Boolean(structure.productionPaused),
|
||||
stallReason: structure.productionStallReason ?? null,
|
||||
inputs: (structure.productionInputs ?? []).map(recipeLine),
|
||||
outputs: (structure.productionOutputs ?? []).map(recipeLine),
|
||||
};
|
||||
|
||||
const modules = {
|
||||
slots: structureType.moduleSlots ?? 0,
|
||||
installed: (structure.installedModules ?? []).map((entry) => ({
|
||||
name: entry.name,
|
||||
installedAt: entry.installedAt ?? null,
|
||||
})),
|
||||
options: (structureType.moduleOptions ?? []).map((option) => ({
|
||||
name: option.name,
|
||||
category: option.category,
|
||||
description: option.description ?? null,
|
||||
effects: (option.effects ?? []).map((effect) => ({
|
||||
type: effect.type ?? "",
|
||||
value: effect.value ?? 0,
|
||||
})),
|
||||
materials: (option.materials ?? []).map((material) => ({
|
||||
name:
|
||||
typeof material.resource === "object" && material.resource !== null
|
||||
? material.resource.name
|
||||
: `Resource #${material.resource}`,
|
||||
amount: Math.ceil(material.amount ?? 0),
|
||||
})),
|
||||
})),
|
||||
};
|
||||
|
||||
return {
|
||||
structure,
|
||||
blueprint: structureType,
|
||||
description,
|
||||
imageUrl,
|
||||
derived: { currentMass, utilization, gridSlots, totalValue, constructionMaterials },
|
||||
derived: {
|
||||
currentMass,
|
||||
utilization,
|
||||
gridSlots,
|
||||
totalValue,
|
||||
constructionMaterials,
|
||||
constructionCrew,
|
||||
},
|
||||
upgrade,
|
||||
production,
|
||||
modules,
|
||||
staffing: {
|
||||
structureStaffing,
|
||||
staff,
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ 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 { ProductionPanel } from "@/components/frontend/baseManagement/ProductionPanel";
|
||||
import { SlotsPanel } from "@/components/frontend/baseManagement/SlotsPanel";
|
||||
import { ConstructionPanel } from "@/components/frontend/baseManagement/ConstructionPanel";
|
||||
import {
|
||||
calculateStoredMass,
|
||||
|
|
@ -20,6 +22,8 @@ import {
|
|||
} from "@/lib/logistics";
|
||||
import { pluralize } from "@/lib/utils";
|
||||
import type { LaborClassificationOption, StructureStaffingOption } from "@/lib/base";
|
||||
import { effectiveCrew } from "@/lib/base/constructionSpeed";
|
||||
import { installedModuleEffects } from "@/lib/base/modules";
|
||||
import { currencyConfigFromGameRules } from "@/lib/banking";
|
||||
import { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQualification";
|
||||
import { isSuperuser } from "@/utils/access-control/hasPermission";
|
||||
|
|
@ -71,9 +75,33 @@ export default async function StructureDetailPage({ params }: StructureDetailPag
|
|||
|
||||
const structureType = structure.type;
|
||||
const storedResources = structure.storedResources ?? [];
|
||||
const compoundHub =
|
||||
typeof structure.parentStructure === "object" && structure.parentStructure !== null
|
||||
? structure.parentStructure
|
||||
: null;
|
||||
const compoundChildren = (
|
||||
await payload.find({
|
||||
collection: "game-structures",
|
||||
where: { parentStructure: { equals: structure.id } },
|
||||
limit: 50,
|
||||
depth: 0,
|
||||
})
|
||||
).docs.map((doc) => ({
|
||||
id: doc.id,
|
||||
name: doc.name,
|
||||
constructionStatus: doc.constructionStatus ?? "complete",
|
||||
}));
|
||||
const currentMass = calculateStoredMass(storedResources);
|
||||
const utilization = calculateUtilization(structureType, storedResources);
|
||||
const gridSlots = getGridSlots(structureType);
|
||||
const moduleEffects = installedModuleEffects(
|
||||
structureType,
|
||||
(structure.installedModules ?? []).map((entry) => entry.name),
|
||||
);
|
||||
const utilization = calculateUtilization(
|
||||
structureType,
|
||||
storedResources,
|
||||
moduleEffects.capacityMass,
|
||||
);
|
||||
const gridSlots = getGridSlots(structureType, moduleEffects.storageSlots);
|
||||
const functionLabel = getFunctionTypeLabel(structureType.functionType);
|
||||
const description = structureType.description
|
||||
? convertLexicalToPlaintext({ data: structureType.description })
|
||||
|
|
@ -193,6 +221,9 @@ export default async function StructureDetailPage({ params }: StructureDetailPag
|
|||
limit: 100,
|
||||
depth: 0,
|
||||
});
|
||||
const buildEfficiencyById = new Map(
|
||||
classificationsRes.docs.map((doc) => [doc.id, doc.buildEfficiency ?? 1]),
|
||||
);
|
||||
const classifications: LaborClassificationOption[] = classificationsRes.docs.map((doc) => ({
|
||||
id: doc.id,
|
||||
name: doc.name,
|
||||
|
|
@ -200,6 +231,20 @@ export default async function StructureDetailPage({ params }: StructureDetailPag
|
|||
defaultUnitSalary: doc.defaultUnitSalary ?? null,
|
||||
availableHeadcount: doc.availableHeadcount ?? 0,
|
||||
}));
|
||||
const constructionCrew = {
|
||||
required: structureType.constructionCrewRequired ?? 0,
|
||||
effective: effectiveCrew(
|
||||
staff
|
||||
.filter(
|
||||
(member) => member.positionType === "labor" && member.laborClassificationId !== null,
|
||||
)
|
||||
.map((member) => ({
|
||||
classificationId: member.laborClassificationId as number,
|
||||
headcount: member.headcount,
|
||||
efficiency: buildEfficiencyById.get(member.laborClassificationId as number) ?? 1,
|
||||
})),
|
||||
),
|
||||
};
|
||||
|
||||
const rules = await payload.findGlobal({
|
||||
slug: "game-rules",
|
||||
|
|
@ -434,6 +479,45 @@ export default async function StructureDetailPage({ params }: StructureDetailPag
|
|||
)}
|
||||
</div>
|
||||
|
||||
{(compoundHub || compoundChildren.length > 0) && (
|
||||
<div className="rounded-lg border border-border p-5 flex flex-col gap-2">
|
||||
<h2 className="text-sm font-semibold">Compound</h2>
|
||||
{compoundHub && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Part of{" "}
|
||||
<a
|
||||
className="text-foreground underline underline-offset-2 hover:text-primary"
|
||||
href={`/logistics/structures/${compoundHub.id}`}
|
||||
>
|
||||
{compoundHub.name}
|
||||
</a>
|
||||
{"; storage pools into the hub while this building is operational."}
|
||||
</p>
|
||||
)}
|
||||
{compoundChildren.length > 0 && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
{compoundChildren.length} child building{compoundChildren.length === 1 ? "" : "s"}
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{compoundChildren.map((child) => (
|
||||
<a
|
||||
key={child.id}
|
||||
href={`/logistics/structures/${child.id}`}
|
||||
className="rounded-md border border-border px-2 py-1 text-xs hover:bg-muted"
|
||||
>
|
||||
{child.name}
|
||||
<span className="ml-1 text-muted-foreground">
|
||||
{child.constructionStatus.replace(/_/g, " ")}
|
||||
</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<UpgradeCard gameStructureId={structure.id} upgrade={upgrade} />
|
||||
|
||||
<ConstructionPanel
|
||||
|
|
@ -442,6 +526,55 @@ export default async function StructureDetailPage({ params }: StructureDetailPag
|
|||
completesAt={structure.constructionCompletesAt ?? null}
|
||||
durationMinutes={structureType.constructionDurationMinutes ?? null}
|
||||
materials={constructionMaterials}
|
||||
crew={constructionCrew}
|
||||
/>
|
||||
|
||||
<ProductionPanel
|
||||
gameStructureId={structure.id}
|
||||
complete={(structure.constructionStatus ?? "complete") === "complete"}
|
||||
paused={Boolean(structure.productionPaused)}
|
||||
stallReason={structure.productionStallReason ?? null}
|
||||
inputs={(structure.productionInputs ?? []).map((line) => ({
|
||||
name:
|
||||
typeof line.resource === "object" && line.resource !== null
|
||||
? line.resource.name
|
||||
: `Resource #${line.resource}`,
|
||||
amountPerHour: line.amountPerHour ?? 0,
|
||||
}))}
|
||||
outputs={(structure.productionOutputs ?? []).map((line) => ({
|
||||
name:
|
||||
typeof line.resource === "object" && line.resource !== null
|
||||
? line.resource.name
|
||||
: `Resource #${line.resource}`,
|
||||
amountPerHour: line.amountPerHour ?? 0,
|
||||
}))}
|
||||
canManage={canManage}
|
||||
/>
|
||||
|
||||
<SlotsPanel
|
||||
gameStructureId={structure.id}
|
||||
slots={structureType.moduleSlots ?? 0}
|
||||
installed={(structure.installedModules ?? []).map((entry) => ({
|
||||
name: entry.name,
|
||||
installedAt: entry.installedAt ?? null,
|
||||
}))}
|
||||
options={(structureType.moduleOptions ?? []).map((option) => ({
|
||||
name: option.name,
|
||||
category: option.category,
|
||||
description: option.description ?? null,
|
||||
effects: (option.effects ?? []).map((effect) => ({
|
||||
type: effect.type ?? "",
|
||||
value: effect.value ?? 0,
|
||||
})),
|
||||
materials: (option.materials ?? []).map((material) => ({
|
||||
name:
|
||||
typeof material.resource === "object" && material.resource !== null
|
||||
? material.resource.name
|
||||
: `Resource #${material.resource}`,
|
||||
amount: Math.ceil(material.amount ?? 0),
|
||||
})),
|
||||
}))}
|
||||
canManage={canManage}
|
||||
/>
|
||||
|
||||
<StaffRoster
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { isSuperuser } from "@/utils/access-control/hasPermission";
|
|||
import { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQualification";
|
||||
import { emitGameEvent } from "@/utils/event-log/emit";
|
||||
import { EventTypes } from "@/utils/event-log/eventTypes";
|
||||
import { effectiveMaxMass } from "@/lib/base/modules";
|
||||
import { checkStorageDeposit, storageViolationMessage } from "@/lib/storageRules";
|
||||
import { maybeStartConstruction } from "@/lib/map/construction";
|
||||
|
||||
|
|
@ -105,7 +106,7 @@ export async function addResource(
|
|||
}
|
||||
}
|
||||
|
||||
async function addResourceInternal(
|
||||
export async function addResourceInternal(
|
||||
payload: PayloadClient,
|
||||
user: AuthedUser,
|
||||
structureId: number,
|
||||
|
|
@ -150,7 +151,7 @@ async function addResourceInternal(
|
|||
// Check capacity
|
||||
const currentMass = calculateMass(structure.storedResources);
|
||||
const addedMass = (resource.massPerUnit ?? 0) * amount;
|
||||
const maxMass = structureType.maxCapacityMass;
|
||||
const maxMass = effectiveMaxMass(structureType, structure.installedModules);
|
||||
|
||||
if (maxMass && currentMass + addedMass > maxMass) {
|
||||
return {
|
||||
|
|
@ -511,7 +512,7 @@ export async function transferResource(
|
|||
// Check destination capacity
|
||||
const destMass = calculateMass(toStructure.storedResources);
|
||||
const addedMass = (resource.massPerUnit ?? 0) * amount;
|
||||
const destMaxMass = toStructure.type.maxCapacityMass;
|
||||
const destMaxMass = effectiveMaxMass(toStructure.type, toStructure.installedModules);
|
||||
|
||||
if (destMaxMass && destMass + addedMass > destMaxMass) {
|
||||
return {
|
||||
|
|
@ -620,7 +621,7 @@ export async function placeResourceOnGrid(
|
|||
|
||||
const currentMass = calculateMass(structure.storedResources);
|
||||
const addedMass = (resource.massPerUnit ?? 0) * amount;
|
||||
const maxMass = structureType.maxCapacityMass;
|
||||
const maxMass = effectiveMaxMass(structureType, structure.installedModules);
|
||||
if (maxMass && currentMass + addedMass > maxMass) {
|
||||
return {
|
||||
success: false,
|
||||
|
|
|
|||
|
|
@ -2,6 +2,12 @@ import { NextRequest, NextResponse } from "next/server";
|
|||
import config from "@payload-config";
|
||||
import { getPayload } from "payload";
|
||||
import type { GameStructure, Map as MapDoc, Shipment } from "@/payload-types";
|
||||
import { effectiveMaxMass } from "@/lib/base/modules";
|
||||
import {
|
||||
aggregateCompoundStorage,
|
||||
compoundParentId,
|
||||
isOperationalMember,
|
||||
} from "@/lib/map/compounds";
|
||||
import { isPointArray, type MapPoint } from "@/lib/map/points";
|
||||
import type { MapFeaturesResponse, StructureFeature } from "@/lib/map/types";
|
||||
|
||||
|
|
@ -132,9 +138,11 @@ export async function GET(req: NextRequest) {
|
|||
constructionCompletesAt: structure.constructionCompletesAt ?? null,
|
||||
constructionDurationMinutes: blueprint?.constructionDurationMinutes ?? null,
|
||||
storageUsedKg,
|
||||
storageCapacityKg: blueprint?.maxCapacityMass ?? null,
|
||||
storageCapacityKg: blueprint ? effectiveMaxMass(blueprint, structure.installedModules) : null,
|
||||
labelVisible: structure.labelVisible ?? null,
|
||||
labelPriority: structure.labelPriority ?? null,
|
||||
parentId: compoundParentId(structure),
|
||||
compoundChildren: 0,
|
||||
blueprintId: blueprint?.id ?? null,
|
||||
blueprintName: blueprint?.name ?? null,
|
||||
blueprintTerrain: (blueprint?.terrainType as "land" | "water" | undefined) ?? null,
|
||||
|
|
@ -160,6 +168,26 @@ export async function GET(req: NextRequest) {
|
|||
});
|
||||
}
|
||||
|
||||
// Compound pooling: children fold their storage into the hub feature and lose
|
||||
// their own map labels; the hub carries the compound label and ribbon totals.
|
||||
const featuresById = new Map(structures.map((feature) => [feature.id, feature]));
|
||||
const childrenByHub = new Map<number, StructureFeature[]>();
|
||||
for (const feature of structures) {
|
||||
if (feature.parentId === null) continue;
|
||||
feature.labelVisible = false;
|
||||
const siblings = childrenByHub.get(feature.parentId) ?? [];
|
||||
siblings.push(feature);
|
||||
childrenByHub.set(feature.parentId, siblings);
|
||||
}
|
||||
for (const [hubId, children] of childrenByHub) {
|
||||
const hub = featuresById.get(hubId);
|
||||
if (!hub) continue;
|
||||
const totals = aggregateCompoundStorage(hub, children);
|
||||
hub.storageUsedKg = totals.storageUsedKg;
|
||||
hub.storageCapacityKg = totals.storageCapacityKg;
|
||||
hub.compoundChildren = children.length;
|
||||
}
|
||||
|
||||
const relEntries = (value: unknown): { id: number; name: string }[] =>
|
||||
(Array.isArray(value) ? value : []).map((entry) =>
|
||||
typeof entry === "object" && entry !== null && "id" in entry
|
||||
|
|
|
|||
|
|
@ -3,6 +3,9 @@ import { requirePermission } from "@/utils/access-control/hasPermission";
|
|||
import { emitGameEvent } from "@/utils/event-log/emit";
|
||||
import { staffingDefaultsFromBlueprint } from "@/lib/base/staffingDefaults";
|
||||
import { createStaffingFields } from "./staffingFields";
|
||||
import { createProductionFields } from "./productionFields";
|
||||
import { createModuleFields } from "./moduleFields";
|
||||
import { productionDefaultsFromBlueprint } from "@/lib/base/productionDefaults";
|
||||
|
||||
export const GameStructures: CollectionConfig = {
|
||||
slug: "game-structures",
|
||||
|
|
@ -28,6 +31,7 @@ export const GameStructures: CollectionConfig = {
|
|||
overrideAccess: true,
|
||||
});
|
||||
const defaults = staffingDefaultsFromBlueprint(blueprint);
|
||||
const productionDefaults = productionDefaultsFromBlueprint(blueprint);
|
||||
|
||||
return {
|
||||
...data,
|
||||
|
|
@ -35,6 +39,12 @@ export const GameStructures: CollectionConfig = {
|
|||
? { laborRequirements: defaults.laborRequirements }
|
||||
: {}),
|
||||
...(data.staffPositions === undefined ? { staffPositions: defaults.staffPositions } : {}),
|
||||
...(data.productionInputs === undefined
|
||||
? { productionInputs: productionDefaults.productionInputs }
|
||||
: {}),
|
||||
...(data.productionOutputs === undefined
|
||||
? { productionOutputs: productionDefaults.productionOutputs }
|
||||
: {}),
|
||||
...(data.maintenanceCost === undefined
|
||||
? { maintenanceCost: defaults.maintenanceCost }
|
||||
: {}),
|
||||
|
|
@ -127,7 +137,8 @@ export const GameStructures: CollectionConfig = {
|
|||
type: "date",
|
||||
label: "Construction Started At",
|
||||
admin: {
|
||||
description: "When construction began (materials consumed). Empty while awaiting materials.",
|
||||
description:
|
||||
"When construction began (materials consumed). Empty while awaiting materials.",
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
@ -139,6 +150,30 @@ export const GameStructures: CollectionConfig = {
|
|||
},
|
||||
},
|
||||
...createStaffingFields("instance"),
|
||||
...createProductionFields("instance"),
|
||||
...createModuleFields("instance"),
|
||||
{
|
||||
name: "productionPaused",
|
||||
type: "checkbox",
|
||||
defaultValue: false,
|
||||
admin: {
|
||||
description: "When checked the structure stops producing until unchecked.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "lastProductionAt",
|
||||
type: "date",
|
||||
admin: { readOnly: true, description: "Last game tick that ran this structure's recipe." },
|
||||
},
|
||||
{
|
||||
name: "productionStallReason",
|
||||
type: "text",
|
||||
admin: {
|
||||
readOnly: true,
|
||||
description:
|
||||
"Why production is not running: no_labor, missing_inputs, storage_full. Empty = running.",
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Geolocation",
|
||||
type: "group",
|
||||
|
|
@ -174,6 +209,16 @@ export const GameStructures: CollectionConfig = {
|
|||
"Label priority for clustering (0-100). Higher wins when structures overlap at low zoom; ties break by stored mass.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "parentStructure",
|
||||
type: "relationship",
|
||||
relationTo: "game-structures",
|
||||
label: "Compound Hub",
|
||||
admin: {
|
||||
description:
|
||||
"Set when this building is part of a compound: its storage pools into the hub, which carries the compound's map label and ribbon.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "storedResources",
|
||||
type: "array",
|
||||
|
|
|
|||
|
|
@ -52,6 +52,17 @@ export const LaborClassifications: CollectionConfig = {
|
|||
type: "textarea",
|
||||
label: "Description",
|
||||
},
|
||||
{
|
||||
name: "buildEfficiency",
|
||||
type: "number",
|
||||
min: 0,
|
||||
defaultValue: 1,
|
||||
label: "Build efficiency",
|
||||
admin: {
|
||||
description:
|
||||
"Construction speed this trade contributes per worker while building structures (1 = a full worker).",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "availableHeadcount",
|
||||
type: "number",
|
||||
|
|
|
|||
46
src/collections/game/auraFields.ts
Normal file
46
src/collections/game/auraFields.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import type { Field } from "payload";
|
||||
|
||||
export const AURA_EFFECT_TYPES = ["production_multiplier", "capacity_mass"] as const;
|
||||
|
||||
/**
|
||||
* Blueprint adjacency aura: structures of this type project one effect onto
|
||||
* operational structures within range (meters). Instances read the aura live
|
||||
* from their blueprint; no instance fields.
|
||||
*/
|
||||
export function createAuraFields(): Field[] {
|
||||
return [
|
||||
{
|
||||
name: "aura",
|
||||
type: "group",
|
||||
label: "Adjacency Aura",
|
||||
admin: {
|
||||
description: "Projected onto nearby operational structures (neighbors only, not itself).",
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: "effectType",
|
||||
type: "select",
|
||||
options: [...AURA_EFFECT_TYPES],
|
||||
label: "Aura Effect",
|
||||
admin: {
|
||||
description:
|
||||
"production_multiplier: boosts the neighbor's production output (value 0.25 = +25%). capacity_mass: raises the neighbor's storage capacity (value in kg).",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "value",
|
||||
type: "number",
|
||||
min: 0,
|
||||
label: "Aura Value",
|
||||
},
|
||||
{
|
||||
name: "rangeMeters",
|
||||
type: "number",
|
||||
min: 0,
|
||||
defaultValue: 0,
|
||||
label: "Aura Range (meters)",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
131
src/collections/game/moduleFields.ts
Normal file
131
src/collections/game/moduleFields.ts
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
import type { Field } from "payload";
|
||||
|
||||
type ModuleFieldContext = "defaults" | "instance";
|
||||
|
||||
export const MODULE_CATEGORIES = ["production", "logistics", "defense", "utility"] as const;
|
||||
|
||||
export const MODULE_EFFECT_TYPES = [
|
||||
"capacity_mass",
|
||||
"production_multiplier",
|
||||
"storage_slots",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Module system fields. Blueprints declare their slot count and the catalog of
|
||||
* installable modules (with effects and install costs). Instances only record
|
||||
* which catalog entries are installed; slots and effects always read live from
|
||||
* the blueprint, and installation is driven by the module server actions
|
||||
* because it consumes stored materials.
|
||||
*/
|
||||
export function createModuleFields(context: ModuleFieldContext): Field[] {
|
||||
const defaults = context === "defaults";
|
||||
|
||||
if (defaults) {
|
||||
return [
|
||||
{
|
||||
name: "moduleSlots",
|
||||
type: "number",
|
||||
min: 0,
|
||||
defaultValue: 0,
|
||||
label: "Module Slots",
|
||||
admin: {
|
||||
description:
|
||||
"How many modules can be installed in structures of this type. 0 = no module support.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "moduleOptions",
|
||||
type: "array",
|
||||
label: "Module Options",
|
||||
admin: {
|
||||
description: "Modules that can be installed into structures of this type.",
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: "name",
|
||||
type: "text",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: "category",
|
||||
type: "select",
|
||||
options: [...MODULE_CATEGORIES],
|
||||
defaultValue: "utility",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: "description",
|
||||
type: "textarea",
|
||||
},
|
||||
{
|
||||
name: "effects",
|
||||
type: "array",
|
||||
required: true,
|
||||
fields: [
|
||||
{
|
||||
name: "type",
|
||||
type: "select",
|
||||
options: [...MODULE_EFFECT_TYPES],
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: "value",
|
||||
type: "number",
|
||||
required: true,
|
||||
admin: {
|
||||
description:
|
||||
"capacity_mass: extra kg of storage. storage_slots: extra grid slots. production_multiplier: output boost as a fraction (0.25 = +25%).",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "materials",
|
||||
type: "array",
|
||||
required: true,
|
||||
fields: [
|
||||
{
|
||||
name: "resource",
|
||||
type: "relationship",
|
||||
relationTo: "resources",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: "amount",
|
||||
type: "number",
|
||||
min: 1,
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
name: "installedModules",
|
||||
type: "array",
|
||||
label: "Installed Modules",
|
||||
admin: {
|
||||
readOnly: true,
|
||||
description: "Managed by the install/uninstall module actions.",
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: "name",
|
||||
type: "text",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: "installedAt",
|
||||
type: "date",
|
||||
admin: {
|
||||
readOnly: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
45
src/collections/game/productionFields.ts
Normal file
45
src/collections/game/productionFields.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import type { Field } from "payload";
|
||||
|
||||
type ProductionFieldContext = "instance" | "defaults";
|
||||
|
||||
/**
|
||||
* Production recipe fields: blueprint arrays are the defaults copied into
|
||||
* newly created game-structures; instances carry the same arrays (editable by
|
||||
* managers) plus runtime controls.
|
||||
*/
|
||||
export function createProductionFields(context: ProductionFieldContext): Field[] {
|
||||
const defaults = context === "defaults";
|
||||
const recipeField = (name: "productionInputs" | "productionOutputs", label: string): Field => ({
|
||||
name,
|
||||
type: "array",
|
||||
label,
|
||||
admin: {
|
||||
description: defaults
|
||||
? "Default recipe copied into newly created game-structures of this type. Empty = no recipe."
|
||||
: undefined,
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: "resource",
|
||||
type: "relationship",
|
||||
relationTo: "resources",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: "amountPerHour",
|
||||
type: "number",
|
||||
min: 0,
|
||||
defaultValue: 0,
|
||||
required: true,
|
||||
admin: {
|
||||
description: "Units processed per real hour while running and staffed.",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return [
|
||||
recipeField("productionInputs", "Production Inputs"),
|
||||
recipeField("productionOutputs", "Production Outputs"),
|
||||
];
|
||||
}
|
||||
|
|
@ -3,6 +3,9 @@ import { isDeveloper } from "@/utils/access-control/isRole";
|
|||
import type { Resource } from "@/payload-types";
|
||||
import { emitGameEvent } from "@/utils/event-log/emit";
|
||||
import { createStaffingFields } from "@/collections/game/staffingFields";
|
||||
import { createProductionFields } from "@/collections/game/productionFields";
|
||||
import { createModuleFields } from "@/collections/game/moduleFields";
|
||||
import { createAuraFields } from "@/collections/game/auraFields";
|
||||
|
||||
export const Structures: CollectionConfig = {
|
||||
slug: "structures",
|
||||
|
|
@ -359,6 +362,17 @@ export const Structures: CollectionConfig = {
|
|||
"How many real minutes it takes to complete construction of this structure after materials are delivered and building starts.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "constructionCrewRequired",
|
||||
type: "number",
|
||||
min: 0,
|
||||
defaultValue: 0,
|
||||
label: "Construction Crew Required",
|
||||
admin: {
|
||||
description:
|
||||
"Effective crew needed for the listed build time. 0 builds at full speed with no labor. Understaffed sites build proportionally slower.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "requiredTech",
|
||||
type: "relationship",
|
||||
|
|
@ -498,6 +512,17 @@ export const Structures: CollectionConfig = {
|
|||
type: "group",
|
||||
fields: createStaffingFields("defaults"),
|
||||
},
|
||||
{
|
||||
label: "Production Recipe Defaults",
|
||||
type: "group",
|
||||
fields: createProductionFields("defaults"),
|
||||
},
|
||||
{
|
||||
label: "Modules",
|
||||
type: "group",
|
||||
fields: createModuleFields("defaults"),
|
||||
},
|
||||
...createAuraFields(),
|
||||
|
||||
{
|
||||
label: "Production & Function",
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ interface ConstructionPanelProps {
|
|||
completesAt: string | null;
|
||||
durationMinutes: number | null;
|
||||
materials: ConstructionMaterial[];
|
||||
crew?: { required: number; effective: number } | null;
|
||||
}
|
||||
|
||||
const STATUS_LABELS: Record<string, { label: string; className: string }> = {
|
||||
|
|
@ -48,6 +49,7 @@ export function ConstructionPanel({
|
|||
completesAt,
|
||||
durationMinutes,
|
||||
materials,
|
||||
crew,
|
||||
}: ConstructionPanelProps) {
|
||||
const [nowMs, setNowMs] = useState(() => Date.now());
|
||||
|
||||
|
|
@ -62,6 +64,30 @@ export function ConstructionPanel({
|
|||
const allDelivered = materials.every((material) => material.delivered >= material.required);
|
||||
const pct = status === "building" ? progressPct(startedAt, completesAt, nowMs) : 0;
|
||||
|
||||
const crewRequired = crew?.required ?? 0;
|
||||
const crewEffective = crew?.effective ?? 0;
|
||||
const crewFactor = crewRequired > 0 ? Math.min(1, crewEffective / crewRequired) : 1;
|
||||
const crewLine =
|
||||
crewRequired > 0
|
||||
? crewEffective <= 0
|
||||
? {
|
||||
text:
|
||||
status === "building"
|
||||
? "No crew assigned: construction is crawling at minimum speed."
|
||||
: "No crew assigned: construction cannot start once materials arrive.",
|
||||
className: "text-amber-300",
|
||||
}
|
||||
: crewFactor < 1
|
||||
? {
|
||||
text: `Crew ${crewEffective.toLocaleString()} / ${crewRequired.toLocaleString()}: understaffed, building at ${Math.round(crewFactor * 100)}% speed.`,
|
||||
className: "text-amber-300",
|
||||
}
|
||||
: {
|
||||
text: `Crew ${crewEffective.toLocaleString()} / ${crewRequired.toLocaleString()}: full build speed.`,
|
||||
className: "text-emerald-400",
|
||||
}
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border p-5 flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
|
|
@ -82,10 +108,7 @@ export function ConstructionPanel({
|
|||
<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 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} />
|
||||
|
|
@ -100,6 +123,8 @@ export function ConstructionPanel({
|
|||
</p>
|
||||
)}
|
||||
|
||||
{crewLine && <p className={`text-xs ${crewLine.className}`}>{crewLine.text}</p>}
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{materials.map((material) => {
|
||||
const met = material.delivered >= material.required;
|
||||
|
|
|
|||
148
src/components/frontend/baseManagement/ProductionPanel.tsx
Normal file
148
src/components/frontend/baseManagement/ProductionPanel.tsx
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import { Pause, Play } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Item, ItemContent, ItemDescription, ItemTitle } from "@/components/ui/item";
|
||||
import { useGameTick } from "@/hooks/useGameTick";
|
||||
import { toggleProduction } from "@/app/(frontend)/logistics/base/actions";
|
||||
|
||||
export interface ProductionRecipeLine {
|
||||
readonly name: string;
|
||||
readonly amountPerHour: number;
|
||||
}
|
||||
|
||||
export interface ProductionPanelProps {
|
||||
readonly gameStructureId: number;
|
||||
readonly complete: boolean;
|
||||
readonly paused: boolean;
|
||||
readonly stallReason: string | null;
|
||||
readonly inputs: readonly ProductionRecipeLine[];
|
||||
readonly outputs: readonly ProductionRecipeLine[];
|
||||
readonly canManage: boolean;
|
||||
}
|
||||
|
||||
const STALL_LABELS: Record<string, string> = {
|
||||
no_labor: "No active labor crew",
|
||||
missing_inputs: "Missing input materials",
|
||||
storage_full: "Storage is full",
|
||||
};
|
||||
|
||||
export function ProductionPanel({
|
||||
gameStructureId,
|
||||
complete,
|
||||
paused,
|
||||
stallReason,
|
||||
inputs,
|
||||
outputs,
|
||||
canManage,
|
||||
}: ProductionPanelProps) {
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(false);
|
||||
useGameTick(() => router.refresh());
|
||||
|
||||
if (!complete) return null;
|
||||
if (inputs.length === 0 && outputs.length === 0) return null;
|
||||
|
||||
const toggle = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await toggleProduction(gameStructureId, !paused);
|
||||
if (result.success) {
|
||||
toast.success(!paused ? "Production resumed." : "Production paused.");
|
||||
router.refresh();
|
||||
} else {
|
||||
toast.error(result.error ?? "Could not change production.");
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const statusText = paused
|
||||
? "Paused"
|
||||
: stallReason
|
||||
? (STALL_LABELS[stallReason] ?? stallReason)
|
||||
: "Running";
|
||||
|
||||
return (
|
||||
<Item className="rounded-none">
|
||||
<ItemContent>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<ItemTitle className="flex items-center gap-2">
|
||||
Production
|
||||
<span
|
||||
className={
|
||||
paused || stallReason
|
||||
? "text-xs font-normal normal-case text-amber-400"
|
||||
: "text-xs font-normal normal-case text-emerald-400"
|
||||
}
|
||||
>
|
||||
{statusText}
|
||||
</span>
|
||||
</ItemTitle>
|
||||
<ItemDescription>
|
||||
Runs per game tick while staffed; outputs land in void storage.
|
||||
</ItemDescription>
|
||||
</div>
|
||||
{canManage && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={toggle}
|
||||
disabled={loading}
|
||||
aria-label={paused ? "Resume production" : "Pause production"}
|
||||
>
|
||||
{paused ? <Play className="size-4" /> : <Pause className="size-4" />}
|
||||
{paused ? "Resume" : "Pause"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{stallReason === "missing_inputs" && (
|
||||
<p className="text-xs text-amber-400/80">
|
||||
Deliver the input materials below to resume the recipe.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="mt-2 grid grid-cols-1 gap-2 text-xs sm:grid-cols-2">
|
||||
<div>
|
||||
<p className="mb-1 font-mono text-[10px] uppercase tracking-[0.14em] text-white/50">
|
||||
Inputs / hour
|
||||
</p>
|
||||
{inputs.length === 0 ? (
|
||||
<p className="text-muted-foreground">None</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-0.5">
|
||||
{inputs.map((line) => (
|
||||
<li key={line.name} className="tabular-nums">
|
||||
{line.amountPerHour.toLocaleString()} {line.name}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-1 font-mono text-[10px] uppercase tracking-[0.14em] text-white/50">
|
||||
Outputs / hour
|
||||
</p>
|
||||
{outputs.length === 0 ? (
|
||||
<p className="text-muted-foreground">None</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-0.5">
|
||||
{outputs.map((line) => (
|
||||
<li key={line.name} className="tabular-nums">
|
||||
{line.amountPerHour.toLocaleString()} {line.name}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
);
|
||||
}
|
||||
151
src/components/frontend/baseManagement/SlotsPanel.tsx
Normal file
151
src/components/frontend/baseManagement/SlotsPanel.tsx
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import { Blocks, Plus, X } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Item, ItemContent, ItemDescription, ItemTitle } from "@/components/ui/item";
|
||||
import { useGameTick } from "@/hooks/useGameTick";
|
||||
import { installModule, uninstallModule } from "@/app/(frontend)/logistics/base/actions";
|
||||
|
||||
export interface ModuleEffectLine {
|
||||
readonly type: string;
|
||||
readonly value: number;
|
||||
}
|
||||
|
||||
export interface ModuleOptionLine {
|
||||
readonly name: string;
|
||||
readonly category: string;
|
||||
readonly description: string | null;
|
||||
readonly effects: readonly ModuleEffectLine[];
|
||||
readonly materials: readonly { name: string; amount: number }[];
|
||||
}
|
||||
|
||||
export interface SlotsPanelProps {
|
||||
readonly gameStructureId: number;
|
||||
readonly slots: number;
|
||||
readonly installed: readonly { name: string; installedAt: string | null }[];
|
||||
readonly options: readonly ModuleOptionLine[];
|
||||
readonly canManage: boolean;
|
||||
}
|
||||
|
||||
const EFFECT_LABELS: Record<string, (value: number) => string> = {
|
||||
capacity_mass: (value) => `+${value.toLocaleString()} kg storage`,
|
||||
storage_slots: (value) => `+${value} grid slots`,
|
||||
production_multiplier: (value) => `+${Math.round(value * 100)}% production`,
|
||||
};
|
||||
|
||||
function effectLabel(effect: ModuleEffectLine): string {
|
||||
const formatter = EFFECT_LABELS[effect.type];
|
||||
return formatter ? formatter(effect.value) : `${effect.type} ${effect.value}`;
|
||||
}
|
||||
|
||||
export function SlotsPanel({
|
||||
gameStructureId,
|
||||
slots,
|
||||
installed,
|
||||
options,
|
||||
canManage,
|
||||
}: SlotsPanelProps) {
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState<string | null>(null);
|
||||
useGameTick(() => router.refresh());
|
||||
|
||||
if (slots <= 0 && options.length === 0 && installed.length === 0) return null;
|
||||
|
||||
const run = async (action: typeof installModule, moduleName: string, successMessage: string) => {
|
||||
setLoading(moduleName);
|
||||
try {
|
||||
const result = await action(gameStructureId, moduleName);
|
||||
if (result.success) {
|
||||
toast.success(successMessage);
|
||||
router.refresh();
|
||||
} else {
|
||||
toast.error(result.error ?? "Could not change modules.");
|
||||
}
|
||||
} finally {
|
||||
setLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const installedNames = new Set(installed.map((entry) => entry.name));
|
||||
const available = slots - installed.length;
|
||||
|
||||
return (
|
||||
<Item className="rounded-none">
|
||||
<ItemContent>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<ItemTitle className="flex items-center gap-2">
|
||||
<Blocks className="size-4 text-muted-foreground" />
|
||||
Modules
|
||||
</ItemTitle>
|
||||
<span
|
||||
className={`text-xs tabular-nums ${available > 0 ? "text-muted-foreground" : "text-amber-400"}`}
|
||||
>
|
||||
{installed.length} / {slots} slots
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{installed.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{installed.map((entry) => (
|
||||
<Badge key={entry.name} variant="outline" className="gap-1 pr-1">
|
||||
{entry.name}
|
||||
{canManage && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Uninstall ${entry.name}`}
|
||||
disabled={loading !== null}
|
||||
onClick={() => run(uninstallModule, entry.name, `${entry.name} uninstalled.`)}
|
||||
className="ml-0.5 rounded-sm p-0.5 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
)}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canManage && available > 0 && options.length > 0 && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{options
|
||||
.filter((option) => !installedNames.has(option.name))
|
||||
.map((option) => (
|
||||
<div
|
||||
key={option.name}
|
||||
className="flex items-center justify-between gap-3 rounded-md bg-muted/40 px-3 py-2"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm">{option.name}</p>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{option.effects.map(effectLabel).join(", ") || "No effects"}
|
||||
{option.materials.length > 0 &&
|
||||
` | needs ${option.materials
|
||||
.map((material) => `${material.amount} ${material.name}`)
|
||||
.join(", ")}`}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={loading !== null}
|
||||
onClick={() => run(installModule, option.name, `${option.name} installed.`)}
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
Install
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{slots > 0 && installed.length === 0 && options.length === 0 && (
|
||||
<ItemDescription>No modules are defined for this structure type.</ItemDescription>
|
||||
)}
|
||||
</ItemContent>
|
||||
</Item>
|
||||
);
|
||||
}
|
||||
|
|
@ -29,7 +29,13 @@ import {
|
|||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useGameTick } from "@/hooks/useGameTick";
|
||||
import { distanceSquared, interpolateAlong, polylineLength, type MapPoint } from "@/lib/map/points";
|
||||
import {
|
||||
distanceSquared,
|
||||
interpolateAlong,
|
||||
zoneLabelPoint,
|
||||
polylineLength,
|
||||
type MapPoint,
|
||||
} from "@/lib/map/points";
|
||||
import {
|
||||
findSnap,
|
||||
segmentMidpoints,
|
||||
|
|
@ -75,11 +81,15 @@ const STRUCTURE_STATUS: Record<string, { color: string; label: string }> = {
|
|||
// Layer styling is hoisted to module constants: react-leaflet re-applies
|
||||
// pathOptions by reference, and a fresh object literal every render forces a
|
||||
// canvas redraw that glitches mid-zoom-animation.
|
||||
const ZONE_PATH_OPTIONS: Record<"water" | "land" | "territory" | "border", L.PathOptions> = {
|
||||
const ZONE_PATH_OPTIONS: Record<
|
||||
"water" | "land" | "territory" | "border" | "other",
|
||||
L.PathOptions
|
||||
> = {
|
||||
water: { color: "#38bdf8", fillColor: "#38bdf8", fillOpacity: 0.25, weight: 1 },
|
||||
land: { color: "#4ade80", fillColor: "#4ade80", fillOpacity: 0.08, weight: 1 },
|
||||
territory: { color: "#fbbf24", fillColor: "#fbbf24", fillOpacity: 0.1, weight: 1 },
|
||||
border: { color: "#a78bfa", fillColor: "#a78bfa", fillOpacity: 0.06, weight: 1 },
|
||||
other: { color: "#94a3b8", fillColor: "#94a3b8", fillOpacity: 0.08, weight: 1 },
|
||||
};
|
||||
|
||||
const ROAD_PATH_OPTIONS: Record<string, L.PathOptions> = {
|
||||
|
|
@ -705,6 +715,14 @@ function StructurePopup({
|
|||
? `${Math.round(structure.storageUsedKg).toLocaleString()} / ${structure.storageCapacityKg.toLocaleString()} kg`
|
||||
: `${Math.round(structure.storageUsedKg).toLocaleString()} kg`,
|
||||
},
|
||||
...(building || !(structure.compoundChildren != null && structure.compoundChildren > 0)
|
||||
? []
|
||||
: [
|
||||
{
|
||||
label: "Compound",
|
||||
value: `${structure.compoundChildren} building${structure.compoundChildren === 1 ? "" : "s"}`,
|
||||
},
|
||||
]),
|
||||
]}
|
||||
/>
|
||||
<div className="border-t border-white/15 px-3 py-2">
|
||||
|
|
@ -872,6 +890,8 @@ const StructureLayer = memo(function StructureLayer({
|
|||
ribbonMeta = first
|
||||
? `Needs ${first.resourceName} x${first.amount ?? 0}${structure.materials.length > 1 ? ` +${structure.materials.length - 1}` : ""}`
|
||||
: "Awaiting materials";
|
||||
} else if ((structure.compoundChildren ?? 0) > 0) {
|
||||
ribbonMeta = `Compound: ${structure.compoundChildren} building${structure.compoundChildren === 1 ? "" : "s"}`;
|
||||
} else {
|
||||
ribbonMeta = structure.blueprintName ?? "Structure";
|
||||
}
|
||||
|
|
@ -1487,7 +1507,7 @@ export function MapClient({ maps, canEdit, canPlace }: MapClientProps) {
|
|||
zone.name && zone.labelVisible !== false ? (
|
||||
<Marker
|
||||
key={`zone-label-${zone.id}`}
|
||||
position={toLatLng(polygonCentroid(zone.points))}
|
||||
position={toLatLng(zoneLabelPoint(zone.points) ?? polygonCentroid(zone.points))}
|
||||
icon={zoneLabelIcon(zone.name)}
|
||||
interactive={false}
|
||||
/>
|
||||
|
|
@ -1621,7 +1641,7 @@ export function MapClient({ maps, canEdit, canPlace }: MapClientProps) {
|
|||
<Polygon
|
||||
positions={authoring.draftPoints.map(toLatLng)}
|
||||
pathOptions={{
|
||||
color: authoring.fields.zoneType === "water" ? "#38bdf8" : "#4ade80",
|
||||
color: ZONE_PATH_OPTIONS[authoring.fields.zoneType].color ?? "#4ade80",
|
||||
fillOpacity: 0.1,
|
||||
dashArray: "4 6",
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -66,8 +66,22 @@ export function PlacementDialog({
|
|||
const [blueprintId, setBlueprintId] = useState<string>("");
|
||||
const [name, setName] = useState("");
|
||||
const [factionId, setFactionId] = useState<string>("none");
|
||||
const [parentStructureId, setParentStructureId] = useState<string>("none");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
// Compound hubs: operational top-level structures only (children cannot be hubs).
|
||||
const hubOptions = useMemo(
|
||||
() =>
|
||||
structures
|
||||
.filter(
|
||||
(structure) =>
|
||||
(structure.constructionStatus ?? "complete") === "complete" &&
|
||||
structure.parentId === null,
|
||||
)
|
||||
.map((structure) => ({ id: structure.id, name: structure.name })),
|
||||
[structures],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
let cancelled = false;
|
||||
|
|
@ -196,6 +210,7 @@ export function PlacementDialog({
|
|||
setBlueprintId("");
|
||||
setName("");
|
||||
setFactionId("none");
|
||||
setParentStructureId("none");
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
|
|
@ -207,6 +222,7 @@ export function PlacementDialog({
|
|||
blueprintId: blueprint.id,
|
||||
name,
|
||||
factionId: factionId === "none" ? null : Number(factionId),
|
||||
parentStructureId: parentStructureId === "none" ? null : Number(parentStructureId),
|
||||
x: point[0],
|
||||
y: point[1],
|
||||
});
|
||||
|
|
@ -287,6 +303,30 @@ export function PlacementDialog({
|
|||
/>
|
||||
</div>
|
||||
|
||||
{hubOptions.length > 0 && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="place-parent">Compound (optional)</Label>
|
||||
<Combobox
|
||||
id="place-parent"
|
||||
value={parentStructureId}
|
||||
onValueChange={setParentStructureId}
|
||||
searchPlaceholder="Search compounds..."
|
||||
emptyMessage="No compound hubs available."
|
||||
options={[
|
||||
{ value: "none", label: "Standalone building" },
|
||||
...hubOptions.map((hub) => ({
|
||||
value: String(hub.id),
|
||||
label: hub.name,
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Child buildings pool their storage into the hub once operational; the hub carries
|
||||
the compound's map label.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{blueprint && (
|
||||
<div className="rounded-md border border-white/10 bg-black/30 p-3 text-xs text-white/70 flex flex-col gap-1">
|
||||
<div>
|
||||
|
|
|
|||
|
|
@ -10,15 +10,12 @@ import { StructureStorageView } from "@/components/frontend/storage/StructureSto
|
|||
import { StaffRoster } from "@/components/frontend/baseManagement/StaffRoster";
|
||||
import { UpgradeCard } from "@/components/frontend/baseManagement/UpgradeCard";
|
||||
import { ConstructionPanel } from "@/components/frontend/baseManagement/ConstructionPanel";
|
||||
import { ProductionPanel } from "@/components/frontend/baseManagement/ProductionPanel";
|
||||
import { SlotsPanel } from "@/components/frontend/baseManagement/SlotsPanel";
|
||||
import { getStructureDetail } from "@/app/(frontend)/logistics/structures/[id]/detail";
|
||||
import type { StructureDetailData } from "@/app/(frontend)/logistics/structures/[id]/detail-data";
|
||||
import { useGameTick } from "@/hooks/useGameTick";
|
||||
import {
|
||||
BoxIcon,
|
||||
CoinsIcon,
|
||||
MapPinIcon,
|
||||
WrenchIcon,
|
||||
} from "lucide-react";
|
||||
import { BoxIcon, CoinsIcon, MapPinIcon, WrenchIcon } from "lucide-react";
|
||||
import {
|
||||
Item,
|
||||
ItemContent,
|
||||
|
|
@ -92,7 +89,11 @@ export function StructureDetailDrawer({
|
|||
<div className="flex flex-col gap-2">
|
||||
{data.imageUrl && (
|
||||
<div className="relative overflow-hidden rounded-none border border-white/15">
|
||||
<img src={data.imageUrl} alt={structure.name} className="h-36 w-full object-cover" />
|
||||
<img
|
||||
src={data.imageUrl}
|
||||
alt={structure.name}
|
||||
className="h-36 w-full object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-transparent to-transparent" />
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -145,7 +146,9 @@ export function StructureDetailDrawer({
|
|||
</ItemHeader>
|
||||
<ItemContent>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xl font-bold">{data.derived.totalValue.toLocaleString()}</span>
|
||||
<span className="text-xl font-bold">
|
||||
{data.derived.totalValue.toLocaleString()}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">base value</span>
|
||||
</div>
|
||||
</ItemContent>
|
||||
|
|
@ -213,6 +216,25 @@ export function StructureDetailDrawer({
|
|||
completesAt={structure.constructionCompletesAt ?? null}
|
||||
durationMinutes={blueprint.constructionDurationMinutes ?? null}
|
||||
materials={data.derived.constructionMaterials}
|
||||
crew={data.derived.constructionCrew}
|
||||
/>
|
||||
|
||||
<ProductionPanel
|
||||
gameStructureId={structure.id}
|
||||
complete={(structure.constructionStatus ?? "complete") === "complete"}
|
||||
paused={data.production.paused}
|
||||
stallReason={data.production.stallReason}
|
||||
inputs={data.production.inputs}
|
||||
outputs={data.production.outputs}
|
||||
canManage={data.gates.canManage}
|
||||
/>
|
||||
|
||||
<SlotsPanel
|
||||
gameStructureId={structure.id}
|
||||
slots={data.modules.slots}
|
||||
installed={data.modules.installed}
|
||||
options={data.modules.options}
|
||||
canManage={data.gates.canManage}
|
||||
/>
|
||||
|
||||
<StaffRoster
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import {
|
|||
getGridSlots,
|
||||
getResourceCount,
|
||||
} from "@/lib/logistics";
|
||||
import { installedModuleEffects } from "@/lib/base/modules";
|
||||
import { Faction, GameStructure, Structure } from "@/payload-types";
|
||||
import { BoxIcon, Building2Icon, ChevronRightIcon, Grid3x3Icon, MapPinIcon } from "lucide-react";
|
||||
|
||||
|
|
@ -33,9 +34,17 @@ export function StructureCard({ structure }: StructureCardProps) {
|
|||
const storedResources = structure.storedResources ?? [];
|
||||
|
||||
const currentMass = calculateStoredMass(storedResources);
|
||||
const utilization = calculateUtilization(structureType, storedResources);
|
||||
const moduleEffects = installedModuleEffects(
|
||||
structureType,
|
||||
(structure.installedModules ?? []).map((entry) => entry.name),
|
||||
);
|
||||
const utilization = calculateUtilization(
|
||||
structureType,
|
||||
storedResources,
|
||||
moduleEffects.capacityMass,
|
||||
);
|
||||
const resourceCount = getResourceCount(storedResources);
|
||||
const gridSlots = getGridSlots(structureType);
|
||||
const gridSlots = getGridSlots(structureType, moduleEffects.storageSlots);
|
||||
const functionLabel = getFunctionTypeLabel(structureType.functionType);
|
||||
|
||||
return (
|
||||
|
|
|
|||
47
src/lib/base/adjacency.ts
Normal file
47
src/lib/base/adjacency.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import type { MapPoint } from "@/lib/map/points";
|
||||
import { pointDistance } from "@/lib/map/points";
|
||||
|
||||
export interface AuraSourceLike {
|
||||
id: number;
|
||||
coordinates?: MapPoint | null;
|
||||
constructionStatus?: string | null;
|
||||
blueprint?: {
|
||||
aura?: {
|
||||
effectType?: string | null;
|
||||
value?: number | null;
|
||||
rangeMeters?: number | null;
|
||||
} | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface AdjacencyBonus {
|
||||
productionMultiplier: number;
|
||||
capacityMass: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sums the adjacency auras projected onto a site: operational neighbors only
|
||||
* (building/awaiting sites project nothing), each source counts once, a source
|
||||
* never buffs itself, and only sources whose blueprint declares an aura with a
|
||||
* positive value and range inside the distance contribute.
|
||||
*/
|
||||
export function auraBonusAt(
|
||||
target: { id: number; coordinates?: MapPoint | null },
|
||||
sources: AuraSourceLike[],
|
||||
): AdjacencyBonus {
|
||||
const bonus: AdjacencyBonus = { productionMultiplier: 0, capacityMass: 0 };
|
||||
if (!target.coordinates) return bonus;
|
||||
for (const source of sources) {
|
||||
if (source.id === target.id) continue;
|
||||
if ((source.constructionStatus ?? "complete") !== "complete") continue;
|
||||
const aura = source.blueprint?.aura;
|
||||
if (!aura || !aura.effectType) continue;
|
||||
const value = aura.value ?? 0;
|
||||
const range = aura.rangeMeters ?? 0;
|
||||
if (value <= 0 || range <= 0 || !source.coordinates) continue;
|
||||
if (pointDistance(target.coordinates, source.coordinates) > range) continue;
|
||||
if (aura.effectType === "production_multiplier") bonus.productionMultiplier += value;
|
||||
else if (aura.effectType === "capacity_mass") bonus.capacityMass += value;
|
||||
}
|
||||
return bonus;
|
||||
}
|
||||
166
src/lib/base/constructionSpeed.ts
Normal file
166
src/lib/base/constructionSpeed.ts
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
import type { Payload } from "payload";
|
||||
|
||||
export interface ConstructionCrewRow {
|
||||
readonly classificationId: number;
|
||||
readonly headcount: number;
|
||||
readonly efficiency: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A stripped site still crawls along at this fraction of full speed instead of
|
||||
* freezing: the timestamp model has no paused state, and an unbounded build
|
||||
* would never finish.
|
||||
*/
|
||||
export const MIN_BUILD_SPEED_FACTOR = 0.25;
|
||||
|
||||
export function effectiveCrew(rows: ConstructionCrewRow[]): number {
|
||||
return rows.reduce((total, row) => total + row.headcount * row.efficiency, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 1 when the blueprint needs no crew; otherwise the staffed fraction of the
|
||||
* required crew, clamped to full speed (extra crew never over-drives a build).
|
||||
*/
|
||||
export function buildSpeedFactor(rows: ConstructionCrewRow[], crewRequired: number): number {
|
||||
const required = Math.max(0, crewRequired);
|
||||
if (required === 0) return 1;
|
||||
const effective = effectiveCrew(rows);
|
||||
if (effective <= 0) return 0;
|
||||
return Math.min(1, effective / required);
|
||||
}
|
||||
|
||||
export function durationMinutesFor(baseMinutes: number, factor: number): number {
|
||||
const floored = Math.max(factor, MIN_BUILD_SPEED_FACTOR);
|
||||
return Math.max(1, Math.ceil(baseMinutes / floored));
|
||||
}
|
||||
|
||||
export interface RecomputeInput {
|
||||
readonly startedAt: string | Date | null | undefined;
|
||||
readonly completesAt: string | Date | null | undefined;
|
||||
readonly baseMinutes: number;
|
||||
readonly oldFactor: number;
|
||||
readonly newFactor: number;
|
||||
readonly now: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-derives constructionCompletesAt for a building site after a crew change,
|
||||
* preserving the fraction of work already finished. Work-left is expressed in
|
||||
* full-speed minutes (wall minutes x old factor), then re-priced at the new
|
||||
* factor. Returns null when the site has no valid window or is already due.
|
||||
*/
|
||||
export function recomputeCompletesAt(input: RecomputeInput): string | null {
|
||||
const start = input.startedAt ? new Date(input.startedAt).getTime() : NaN;
|
||||
const end = input.completesAt ? new Date(input.completesAt).getTime() : NaN;
|
||||
if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start) return null;
|
||||
if (input.now.getTime() >= end) return null;
|
||||
const oldFloored = Math.max(input.oldFactor, MIN_BUILD_SPEED_FACTOR);
|
||||
const newFloored = Math.max(input.newFactor, MIN_BUILD_SPEED_FACTOR);
|
||||
const remainingBaseMinutes = ((end - input.now.getTime()) / 60000) * oldFloored;
|
||||
return new Date(input.now.getTime() + (remainingBaseMinutes / newFloored) * 60000).toISOString();
|
||||
}
|
||||
|
||||
export async function siteCrewRows(
|
||||
payload: Payload,
|
||||
structureId: number,
|
||||
): Promise<ConstructionCrewRow[]> {
|
||||
const rows = await payload.find({
|
||||
collection: "npc-staffing",
|
||||
where: {
|
||||
and: [
|
||||
{ structure: { equals: structureId } },
|
||||
{ positionType: { equals: "labor" } },
|
||||
{ status: { equals: "active" } },
|
||||
],
|
||||
},
|
||||
depth: 1,
|
||||
limit: 100,
|
||||
overrideAccess: true,
|
||||
});
|
||||
return rows.docs.flatMap((row) => {
|
||||
const rel = row.laborClassification;
|
||||
if (rel === null || rel === undefined) return [];
|
||||
const classification = typeof rel === "object" ? rel : null;
|
||||
return [
|
||||
{
|
||||
classificationId: typeof rel === "object" ? rel.id : rel,
|
||||
headcount: row.headcount ?? 0,
|
||||
efficiency: classification?.buildEfficiency ?? 1,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Rate recompute after a labor headcount change at a building site. Reads the
|
||||
* post-change crew, back-derives the pre-change factor from the old headcount
|
||||
* of the affected classification, and re-prices the remaining work. No-op for
|
||||
* sites that are not building or whose blueprints need no crew.
|
||||
*/
|
||||
export async function applyCrewRateChange(
|
||||
payload: Payload,
|
||||
siteId: number,
|
||||
changedClassificationId: number,
|
||||
oldHeadcount: number,
|
||||
now: Date = new Date(),
|
||||
): Promise<void> {
|
||||
const doc = await payload.findByID({
|
||||
collection: "game-structures",
|
||||
id: siteId,
|
||||
depth: 2,
|
||||
overrideAccess: true,
|
||||
});
|
||||
if (doc.constructionStatus !== "building") return;
|
||||
const blueprint = typeof doc.type === "object" && doc.type !== null ? doc.type : null;
|
||||
if (!blueprint) return;
|
||||
const crewRequired = Math.max(0, blueprint.constructionCrewRequired ?? 0);
|
||||
if (crewRequired <= 0) return;
|
||||
|
||||
const rows = await siteCrewRows(payload, siteId);
|
||||
const newFactor = buildSpeedFactor(rows, crewRequired);
|
||||
// A full release terminates the staffing row before this runs, so the
|
||||
// pre-change row may be gone: rebuild it from the classification record.
|
||||
const changedRow = rows.find((row) => row.classificationId === changedClassificationId);
|
||||
let oldRows: ConstructionCrewRow[];
|
||||
if (changedRow) {
|
||||
oldRows = rows.map((row) =>
|
||||
row.classificationId === changedClassificationId ? { ...row, headcount: oldHeadcount } : row,
|
||||
);
|
||||
} else {
|
||||
const classification = await payload
|
||||
.findByID({
|
||||
collection: "labor-classifications",
|
||||
id: changedClassificationId,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
.catch(() => null);
|
||||
oldRows = [
|
||||
...rows,
|
||||
{
|
||||
classificationId: changedClassificationId,
|
||||
headcount: oldHeadcount,
|
||||
efficiency: classification?.buildEfficiency ?? 1,
|
||||
},
|
||||
];
|
||||
}
|
||||
const oldFactor = buildSpeedFactor(oldRows, crewRequired);
|
||||
if (oldFactor === newFactor) return;
|
||||
|
||||
const nextCompletesAt = recomputeCompletesAt({
|
||||
startedAt: doc.constructionStartedAt,
|
||||
completesAt: doc.constructionCompletesAt,
|
||||
baseMinutes: blueprint.constructionDurationMinutes ?? 0,
|
||||
oldFactor,
|
||||
newFactor,
|
||||
now,
|
||||
});
|
||||
if (!nextCompletesAt) return;
|
||||
|
||||
await payload.update({
|
||||
collection: "game-structures",
|
||||
id: siteId,
|
||||
data: { constructionCompletesAt: nextCompletesAt },
|
||||
overrideAccess: true,
|
||||
});
|
||||
}
|
||||
224
src/lib/base/modules.ts
Normal file
224
src/lib/base/modules.ts
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
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 "./storage";
|
||||
|
||||
type StorageEntry = NonNullable<GameStructure["storedResources"]>[number];
|
||||
type VoidEntry = NonNullable<GameStructure["voidStorage"]>[number];
|
||||
|
||||
export interface ModuleEffectLike {
|
||||
type?: string | null;
|
||||
value?: number | null;
|
||||
}
|
||||
|
||||
export interface ModuleOptionLike {
|
||||
name?: string | null;
|
||||
effects?: ModuleEffectLike[] | null;
|
||||
materials?:
|
||||
{ resource: number | { id: number } | null | undefined; amount?: number | null }[] | null;
|
||||
}
|
||||
|
||||
export interface BlueprintModulesLike {
|
||||
moduleSlots?: number | null;
|
||||
moduleOptions?: ModuleOptionLike[] | null;
|
||||
}
|
||||
|
||||
export interface ModuleEffects {
|
||||
capacityMass: number;
|
||||
productionMultiplier: number;
|
||||
storageSlots: number;
|
||||
}
|
||||
|
||||
export interface InstalledModuleLike {
|
||||
name: string;
|
||||
installedAt: string | null;
|
||||
}
|
||||
|
||||
/** Aggregates the effects of a site's installed modules against its blueprint catalog. */
|
||||
export function installedModuleEffects(
|
||||
blueprint: BlueprintModulesLike,
|
||||
installedNames: string[],
|
||||
): ModuleEffects {
|
||||
const effects: ModuleEffects = {
|
||||
capacityMass: 0,
|
||||
productionMultiplier: 0,
|
||||
storageSlots: 0,
|
||||
};
|
||||
for (const name of installedNames) {
|
||||
const option = (blueprint.moduleOptions ?? []).find((candidate) => candidate.name === name);
|
||||
if (!option) continue;
|
||||
for (const effect of option.effects ?? []) {
|
||||
const value = effect.value ?? 0;
|
||||
if (effect.type === "capacity_mass") effects.capacityMass += value;
|
||||
else if (effect.type === "production_multiplier") effects.productionMultiplier += value;
|
||||
else if (effect.type === "storage_slots") effects.storageSlots += Math.floor(value);
|
||||
}
|
||||
}
|
||||
return effects;
|
||||
}
|
||||
|
||||
/** Mass capacity with module bonus applied; null stays null (uncapped). */
|
||||
export function effectiveCapacityMass(
|
||||
base: number | null | undefined,
|
||||
bonus: number,
|
||||
): number | null {
|
||||
if (base === null || base === undefined || base <= 0) return null;
|
||||
return Math.max(0, base + bonus);
|
||||
}
|
||||
|
||||
/** Mass cap for an instance: blueprint capacity plus its installed capacity_mass modules. */
|
||||
export function effectiveMaxMass(
|
||||
blueprint: BlueprintModulesLike & { maxCapacityMass?: number | null },
|
||||
installedModules: { name: string }[] | null | undefined,
|
||||
): number | null {
|
||||
const effects = installedModuleEffects(
|
||||
blueprint,
|
||||
(installedModules ?? []).map((entry) => entry.name),
|
||||
);
|
||||
return effectiveCapacityMass(blueprint.maxCapacityMass, effects.capacityMass);
|
||||
}
|
||||
|
||||
export function findModuleOption(
|
||||
blueprint: BlueprintModulesLike,
|
||||
moduleName: string,
|
||||
): ModuleOptionLike | null {
|
||||
return (blueprint.moduleOptions ?? []).find((option) => option.name === moduleName) ?? null;
|
||||
}
|
||||
|
||||
function moduleMaterialShortfalls(
|
||||
option: ModuleOptionLike,
|
||||
storedResources: StorageEntry[],
|
||||
voidStorage: VoidEntry[],
|
||||
): string[] {
|
||||
const shortages: string[] = [];
|
||||
for (const material of option.materials ?? []) {
|
||||
const resourceId = relationshipId(material.resource);
|
||||
const needed = Math.ceil(material.amount ?? 0);
|
||||
if (resourceId === null || needed <= 0) continue;
|
||||
const available =
|
||||
storedAmount(storedResources, resourceId) + storedAmount(voidStorage, resourceId);
|
||||
if (available < needed) {
|
||||
shortages.push(`resource #${resourceId} (have ${available}, need ${needed})`);
|
||||
}
|
||||
}
|
||||
return shortages;
|
||||
}
|
||||
|
||||
function consumeModuleMaterials(
|
||||
option: ModuleOptionLike,
|
||||
storedResources: StorageEntry[],
|
||||
voidStorage: VoidEntry[],
|
||||
): { storedResources: StorageEntry[]; voidStorage: VoidEntry[] } {
|
||||
let stored = [...storedResources];
|
||||
let voidEntries = [...voidStorage];
|
||||
for (const material of option.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) {
|
||||
voidEntries = consumeStorage(voidEntries, resourceId, fromStored.remaining).entries;
|
||||
}
|
||||
}
|
||||
return { storedResources: stored, voidStorage: voidEntries };
|
||||
}
|
||||
|
||||
export async function installModule(
|
||||
payload: Payload,
|
||||
structureId: number,
|
||||
moduleName: string,
|
||||
): Promise<InstalledModuleLike[]> {
|
||||
const doc = await payload.findByID({
|
||||
collection: "game-structures",
|
||||
id: structureId,
|
||||
depth: 2,
|
||||
overrideAccess: true,
|
||||
});
|
||||
if (doc.constructionStatus !== "complete") {
|
||||
throw new Error("Modules can only be installed in operational structures.");
|
||||
}
|
||||
const blueprint =
|
||||
typeof doc.type === "object" && doc.type !== null ? (doc.type as Structure) : null;
|
||||
if (!blueprint) throw new Error("This structure has no blueprint.");
|
||||
|
||||
const installed = (doc.installedModules ?? []) as InstalledModuleLike[];
|
||||
if (installed.some((entry) => entry.name === moduleName)) {
|
||||
throw new Error("That module is already installed.");
|
||||
}
|
||||
const slots = blueprint.moduleSlots ?? 0;
|
||||
if (installed.length >= slots) {
|
||||
throw new Error(`All ${slots} module slot(s) are already in use.`);
|
||||
}
|
||||
const option = findModuleOption(blueprint, moduleName);
|
||||
if (!option) throw new Error("Unknown module for this structure type.");
|
||||
|
||||
const storedResources = (doc.storedResources ?? []) as StorageEntry[];
|
||||
const voidStorage = (doc.voidStorage ?? []) as VoidEntry[];
|
||||
const shortages = moduleMaterialShortfalls(option, storedResources, voidStorage);
|
||||
if (shortages.length > 0) {
|
||||
throw new Error(`Missing module materials: ${shortages.join(", ")}.`);
|
||||
}
|
||||
const consumed = consumeModuleMaterials(option, storedResources, voidStorage);
|
||||
const installedAt = new Date().toISOString();
|
||||
const updated = [...installed, { name: moduleName, installedAt }];
|
||||
|
||||
await payload.update({
|
||||
collection: "game-structures",
|
||||
id: structureId,
|
||||
data: {
|
||||
installedModules: updated,
|
||||
storedResources: consumed.storedResources,
|
||||
voidStorage: consumed.voidStorage,
|
||||
},
|
||||
overrideAccess: true,
|
||||
});
|
||||
|
||||
await emitGameEvent(payload, {
|
||||
type: EventTypes.StructureModuleInstall,
|
||||
message: `${doc.name} installed the ${moduleName} module`,
|
||||
structure: structureId,
|
||||
targetCollection: "game-structures",
|
||||
targetId: structureId,
|
||||
data: { module: moduleName },
|
||||
});
|
||||
return updated;
|
||||
}
|
||||
|
||||
export async function uninstallModule(
|
||||
payload: Payload,
|
||||
structureId: number,
|
||||
moduleName: string,
|
||||
): Promise<InstalledModuleLike[]> {
|
||||
const doc = await payload.findByID({
|
||||
collection: "game-structures",
|
||||
id: structureId,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
});
|
||||
const installed = ((doc.installedModules ?? []) as InstalledModuleLike[]).filter(
|
||||
(entry) => entry.name !== moduleName,
|
||||
);
|
||||
const before = (doc.installedModules ?? []) as InstalledModuleLike[];
|
||||
if (installed.length === before.length) {
|
||||
throw new Error("That module is not installed.");
|
||||
}
|
||||
|
||||
await payload.update({
|
||||
collection: "game-structures",
|
||||
id: structureId,
|
||||
data: { installedModules: installed },
|
||||
overrideAccess: true,
|
||||
});
|
||||
|
||||
await emitGameEvent(payload, {
|
||||
type: EventTypes.StructureModuleUninstall,
|
||||
message: `${doc.name} uninstalled the ${moduleName} module (materials not refunded)`,
|
||||
structure: structureId,
|
||||
targetCollection: "game-structures",
|
||||
targetId: structureId,
|
||||
data: { module: moduleName },
|
||||
});
|
||||
return installed;
|
||||
}
|
||||
348
src/lib/base/production.ts
Normal file
348
src/lib/base/production.ts
Normal file
|
|
@ -0,0 +1,348 @@
|
|||
import type { Payload } from "payload";
|
||||
import type { GameStructure, Resource, Structure } from "@/payload-types";
|
||||
import { EventTypes } from "@/utils/event-log/eventTypes";
|
||||
import { emitGameEvent } from "@/utils/event-log/emit";
|
||||
import { consumeStorage, relationshipId, storedAmount } from "./storage";
|
||||
import { effectiveCapacityMass, installedModuleEffects } from "./modules";
|
||||
import { auraBonusAt, type AuraSourceLike } from "./adjacency";
|
||||
import type { MapPoint } from "@/lib/map/points";
|
||||
|
||||
type PayloadType = Awaited<ReturnType<typeof import("payload").getPayload>>;
|
||||
|
||||
export interface ProductionLog {
|
||||
info?: (message: string) => void;
|
||||
error?: (message: string) => void;
|
||||
}
|
||||
|
||||
export interface ProductionTickResult {
|
||||
processed: number;
|
||||
produced: number;
|
||||
stalled: number;
|
||||
}
|
||||
|
||||
type ProductionLine = { resource: number | Resource; amountPerHour?: number | null };
|
||||
type StorageEntry = NonNullable<GameStructure["storedResources"]>[number];
|
||||
type VoidEntry = NonNullable<GameStructure["voidStorage"]>[number];
|
||||
|
||||
/** Cap on accumulated production time so a long outage cannot dump a mountain at once. */
|
||||
const MAX_DELTA_HOURS = 8;
|
||||
|
||||
async function structureBlueprint(
|
||||
payload: PayloadType,
|
||||
structure: GameStructure,
|
||||
): Promise<Structure | null> {
|
||||
const reference = structure.type;
|
||||
if (typeof reference === "object" && reference !== null) return reference as Structure;
|
||||
if (typeof reference !== "number") return null;
|
||||
return payload.findByID({
|
||||
collection: "structures",
|
||||
id: reference,
|
||||
depth: 1,
|
||||
overrideAccess: true,
|
||||
});
|
||||
}
|
||||
|
||||
function capacityKg(blueprint: Structure | null): number | null {
|
||||
const capacity = blueprint?.maxCapacityMass;
|
||||
return capacity != null && capacity > 0 ? capacity : null;
|
||||
}
|
||||
|
||||
function storedMass(
|
||||
storedResources: StorageEntry[],
|
||||
voidStorage: VoidEntry[],
|
||||
resourceById: Map<number, Resource>,
|
||||
): number {
|
||||
const massOf = (entry: { resource: number | Resource; amount?: number | null }): number => {
|
||||
const id = relationshipId(entry.resource);
|
||||
return (resourceById.get(id ?? -1)?.massPerUnit ?? 0) * (entry.amount ?? 0);
|
||||
};
|
||||
return (
|
||||
storedResources.reduce((total, entry) => total + massOf(entry), 0) +
|
||||
voidStorage.reduce((total, entry) => total + massOf(entry), 0)
|
||||
);
|
||||
}
|
||||
|
||||
function mergeIntoVoid(voidStorage: VoidEntry[], resourceId: number, amount: number): VoidEntry[] {
|
||||
if (amount <= 0) return voidStorage;
|
||||
const existing = voidStorage.find((entry) => relationshipId(entry.resource) === resourceId);
|
||||
if (existing) {
|
||||
return voidStorage.map((entry) =>
|
||||
entry === existing ? { ...entry, amount: (entry.amount ?? 0) + amount } : entry,
|
||||
);
|
||||
}
|
||||
return [...voidStorage, { resource: resourceId, amount, gridX: 0, gridY: 0 }];
|
||||
}
|
||||
|
||||
async function hasActiveLabor(payload: PayloadType, structureId: number): Promise<boolean> {
|
||||
const rows = await payload.find({
|
||||
collection: "npc-staffing",
|
||||
where: {
|
||||
and: [
|
||||
{ structure: { equals: structureId } },
|
||||
{ status: { equals: "active" } },
|
||||
{ headcount: { greater_than: 0 } },
|
||||
],
|
||||
},
|
||||
limit: 1,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
});
|
||||
return rows.docs.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs every complete, unpaused structure's production recipe for the time
|
||||
* elapsed since its lastProductionAt: consumes inputs from storage, pushes
|
||||
* outputs into void storage, and stalls with a surfaced reason on missing
|
||||
* labor, missing inputs, or a full store. Stalled time produces nothing (the
|
||||
* clock always advances), and the first tick after completion only primes the
|
||||
* clock so no output exists before a full interval has passed.
|
||||
*/
|
||||
export async function processProductionTick(
|
||||
payload: PayloadType,
|
||||
log?: ProductionLog,
|
||||
now: Date = new Date(),
|
||||
siteIds?: number[],
|
||||
): Promise<ProductionTickResult> {
|
||||
const result: ProductionTickResult = { processed: 0, produced: 0, stalled: 0 };
|
||||
const nowIso = now.toISOString();
|
||||
|
||||
const sites = await payload.find({
|
||||
collection: "game-structures",
|
||||
where: {
|
||||
and: [
|
||||
{ constructionStatus: { equals: "complete" } },
|
||||
...(siteIds && siteIds.length > 0 ? [{ id: { in: siteIds } }] : []),
|
||||
],
|
||||
},
|
||||
limit: 1000,
|
||||
depth: 2,
|
||||
overrideAccess: true,
|
||||
});
|
||||
|
||||
// Aura sources must be unscoped even when siteIds scopes the sweep:
|
||||
// neighbors outside the scope still project their auras inward.
|
||||
const auraSourceDocs =
|
||||
siteIds && siteIds.length > 0
|
||||
? (
|
||||
await payload.find({
|
||||
collection: "game-structures",
|
||||
where: { constructionStatus: { equals: "complete" } },
|
||||
depth: 1,
|
||||
limit: 1000,
|
||||
overrideAccess: true,
|
||||
})
|
||||
).docs
|
||||
: sites.docs;
|
||||
const auraSources: AuraSourceLike[] = auraSourceDocs.map((doc) => {
|
||||
const structure = doc as unknown as GameStructure;
|
||||
return {
|
||||
id: structure.id,
|
||||
coordinates: (structure.coordinates ?? null) as MapPoint | null,
|
||||
constructionStatus: structure.constructionStatus ?? "complete",
|
||||
blueprint: typeof structure.type === "object" ? structure.type : null,
|
||||
};
|
||||
});
|
||||
|
||||
for (const doc of sites.docs) {
|
||||
const structure = doc as unknown as GameStructure;
|
||||
try {
|
||||
if (structure.productionPaused) continue;
|
||||
const outputs = (structure.productionOutputs ?? []) as ProductionLine[];
|
||||
if (outputs.length === 0) continue;
|
||||
const inputs = (structure.productionInputs ?? []) as ProductionLine[];
|
||||
|
||||
const last = structure.lastProductionAt
|
||||
? new Date(structure.lastProductionAt).getTime()
|
||||
: NaN;
|
||||
if (!Number.isFinite(last)) {
|
||||
await payload.update({
|
||||
collection: "game-structures",
|
||||
id: structure.id,
|
||||
data: { lastProductionAt: nowIso },
|
||||
overrideAccess: true,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const deltaHours = Math.max(0, Math.min(MAX_DELTA_HOURS, (now.getTime() - last) / 3_600_000));
|
||||
if (deltaHours <= 0) continue;
|
||||
result.processed++;
|
||||
|
||||
if (!(await hasActiveLabor(payload, structure.id))) {
|
||||
await stall(payload, structure, "no_labor", nowIso);
|
||||
result.stalled++;
|
||||
continue;
|
||||
}
|
||||
|
||||
let storedResources: StorageEntry[] = [...(structure.storedResources ?? [])];
|
||||
let voidStorage: VoidEntry[] = [...(structure.voidStorage ?? [])];
|
||||
|
||||
const shortages: string[] = [];
|
||||
for (const line of inputs) {
|
||||
const resourceId = relationshipId(line.resource);
|
||||
if (resourceId === null) continue;
|
||||
const needed = Math.max(0, (line.amountPerHour ?? 0) * deltaHours);
|
||||
if (needed <= 0) continue;
|
||||
const available =
|
||||
storedAmount(storedResources, resourceId) + storedAmount(voidStorage, resourceId);
|
||||
if (available < needed) {
|
||||
const name =
|
||||
typeof line.resource === "object" ? line.resource.name : `Resource #${line.resource}`;
|
||||
shortages.push(`${name} (need ${needed}, have ${available})`);
|
||||
}
|
||||
}
|
||||
if (shortages.length > 0) {
|
||||
await stall(payload, structure, "missing_inputs", nowIso, shortages.join(", "));
|
||||
result.stalled++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const resourceIds = new Set<number>();
|
||||
for (const line of [...inputs, ...outputs]) {
|
||||
const id = relationshipId(line.resource);
|
||||
if (id !== null) resourceIds.add(id);
|
||||
}
|
||||
const resourceById = new Map<number, Resource>();
|
||||
for (const id of resourceIds) {
|
||||
const resource = await payload.findByID({
|
||||
collection: "resources",
|
||||
id,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
});
|
||||
resourceById.set(id, resource as unknown as Resource);
|
||||
}
|
||||
|
||||
let producedLines = outputs
|
||||
.map((line) => ({
|
||||
resourceId: relationshipId(line.resource),
|
||||
amount: Math.max(0, (line.amountPerHour ?? 0) * deltaHours),
|
||||
}))
|
||||
.filter(
|
||||
(line): line is { resourceId: number; amount: number } =>
|
||||
line.resourceId !== null && line.amount > 0,
|
||||
);
|
||||
if (producedLines.length === 0) {
|
||||
await payload.update({
|
||||
collection: "game-structures",
|
||||
id: structure.id,
|
||||
data: { lastProductionAt: nowIso, productionStallReason: null },
|
||||
overrideAccess: true,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const blueprint = await structureBlueprint(payload, structure);
|
||||
const moduleEffects = installedModuleEffects(
|
||||
blueprint ?? {},
|
||||
((structure.installedModules ?? []) as { name: string }[]).map((entry) => entry.name),
|
||||
);
|
||||
const adjacency = auraBonusAt(
|
||||
{ id: structure.id, coordinates: (structure.coordinates ?? null) as MapPoint | null },
|
||||
auraSources,
|
||||
);
|
||||
const outputBoost = 1 + moduleEffects.productionMultiplier + adjacency.productionMultiplier;
|
||||
if (outputBoost > 1) {
|
||||
producedLines = producedLines.map((line) => ({
|
||||
...line,
|
||||
amount: line.amount * outputBoost,
|
||||
}));
|
||||
}
|
||||
|
||||
const capacity = effectiveCapacityMass(
|
||||
capacityKg(blueprint),
|
||||
moduleEffects.capacityMass + adjacency.capacityMass,
|
||||
);
|
||||
if (capacity !== null) {
|
||||
const free = capacity - storedMass(storedResources, voidStorage, resourceById);
|
||||
if (free <= 0) {
|
||||
await stall(payload, structure, "storage_full", nowIso);
|
||||
result.stalled++;
|
||||
continue;
|
||||
}
|
||||
const incomingMass = producedLines.reduce(
|
||||
(total, line) =>
|
||||
total + (resourceById.get(line.resourceId)?.massPerUnit ?? 0) * line.amount,
|
||||
0,
|
||||
);
|
||||
if (incomingMass > free) {
|
||||
const scale = free / incomingMass;
|
||||
producedLines = producedLines.map((line) => ({ ...line, amount: line.amount * scale }));
|
||||
}
|
||||
}
|
||||
|
||||
for (const line of inputs) {
|
||||
const resourceId = relationshipId(line.resource);
|
||||
if (resourceId === null) continue;
|
||||
const needed = Math.max(0, (line.amountPerHour ?? 0) * deltaHours);
|
||||
if (needed <= 0) continue;
|
||||
const fromGrid = consumeStorage(storedResources, resourceId, needed);
|
||||
storedResources = fromGrid.entries;
|
||||
voidStorage = consumeStorage(voidStorage, resourceId, fromGrid.remaining).entries;
|
||||
}
|
||||
|
||||
for (const line of producedLines) {
|
||||
voidStorage = mergeIntoVoid(voidStorage, line.resourceId, line.amount);
|
||||
}
|
||||
|
||||
await payload.update({
|
||||
collection: "game-structures",
|
||||
id: structure.id,
|
||||
data: {
|
||||
storedResources,
|
||||
voidStorage,
|
||||
lastProductionAt: nowIso,
|
||||
productionStallReason: null,
|
||||
},
|
||||
overrideAccess: true,
|
||||
});
|
||||
|
||||
const summary = producedLines
|
||||
.map(
|
||||
(line) => `${Math.round(line.amount)} ${resourceById.get(line.resourceId)?.name ?? ""}`,
|
||||
)
|
||||
.join(", ");
|
||||
await emitGameEvent(payload, {
|
||||
type: EventTypes.ProductionOutput,
|
||||
message: `${structure.name} produced ${summary}`,
|
||||
structure: structure.id,
|
||||
targetCollection: "game-structures",
|
||||
targetId: structure.id,
|
||||
data: { deltaHours, lines: producedLines },
|
||||
});
|
||||
result.produced++;
|
||||
} catch (error) {
|
||||
log?.error?.(
|
||||
`Production tick failed for structure #${structure.id}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async function stall(
|
||||
payload: PayloadType,
|
||||
structure: GameStructure,
|
||||
reason: string,
|
||||
nowIso: string,
|
||||
detail?: string,
|
||||
): Promise<void> {
|
||||
await payload.update({
|
||||
collection: "game-structures",
|
||||
id: structure.id,
|
||||
data: { productionStallReason: reason, lastProductionAt: nowIso },
|
||||
overrideAccess: true,
|
||||
});
|
||||
if ((structure.productionStallReason ?? null) !== reason) {
|
||||
await emitGameEvent(payload, {
|
||||
type: EventTypes.ProductionPaused,
|
||||
message: `${structure.name} production paused: ${reason}${detail ? ` (${detail})` : ""}`,
|
||||
structure: structure.id,
|
||||
targetCollection: "game-structures",
|
||||
targetId: structure.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
20
src/lib/base/productionDefaults.ts
Normal file
20
src/lib/base/productionDefaults.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import type { Structure } from "@/payload-types";
|
||||
|
||||
export type ProductionDefaults = {
|
||||
readonly productionInputs: NonNullable<Structure["productionInputs"]>;
|
||||
readonly productionOutputs: NonNullable<Structure["productionOutputs"]>;
|
||||
};
|
||||
|
||||
export function productionDefaultsFromBlueprint(blueprint: Structure): ProductionDefaults {
|
||||
return {
|
||||
productionInputs: (blueprint.productionInputs ?? []).map((input) => ({
|
||||
resource:
|
||||
typeof input.resource === "object" ? input.resource.id : input.resource,
|
||||
amountPerHour: input.amountPerHour ?? 0,
|
||||
})),
|
||||
productionOutputs: (blueprint.productionOutputs ?? []).map((output) => ({
|
||||
resource: typeof output.resource === "object" ? output.resource.id : output.resource,
|
||||
amountPerHour: output.amountPerHour ?? 0,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ import type { NpcStaffing } from "@/payload-types";
|
|||
import { emitGameEvent } from "@/utils/event-log/emit";
|
||||
import { EventTypes } from "@/utils/event-log/eventTypes";
|
||||
import { relationshipId } from "./storage";
|
||||
import { applyCrewRateChange } from "./constructionSpeed";
|
||||
import type { HireStaffInput, SetLaborHeadcountInput } from "./types";
|
||||
|
||||
export async function assertStaffHiringEnabled(payload: Payload): Promise<void> {
|
||||
|
|
@ -226,6 +227,7 @@ export async function setLaborHeadcount(
|
|||
targetCollection: "npc-staffing",
|
||||
targetId: staffing.id,
|
||||
});
|
||||
await applyCrewRateChange(payload, structure.id, classification.id, currentHeadcount);
|
||||
}
|
||||
return { id: null };
|
||||
}
|
||||
|
|
@ -272,6 +274,7 @@ export async function setLaborHeadcount(
|
|||
unitSalary,
|
||||
},
|
||||
});
|
||||
await applyCrewRateChange(payload, structure.id, classification.id, currentHeadcount);
|
||||
return { id: updated.id };
|
||||
}
|
||||
|
||||
|
|
|
|||
38
src/lib/map/compounds.ts
Normal file
38
src/lib/map/compounds.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
export interface CompoundMemberLike {
|
||||
id: number;
|
||||
parentStructure?: number | { id: number } | null;
|
||||
constructionStatus?: string | null;
|
||||
storageUsedKg?: number | null;
|
||||
storageCapacityKg?: number | null;
|
||||
}
|
||||
|
||||
export function compoundParentId(member: CompoundMemberLike): number | null {
|
||||
const parent = member.parentStructure;
|
||||
if (parent === null || parent === undefined) return null;
|
||||
return typeof parent === "object" ? parent.id : parent;
|
||||
}
|
||||
|
||||
export function isOperationalMember(member: CompoundMemberLike): boolean {
|
||||
return (member.constructionStatus ?? "complete") === "complete";
|
||||
}
|
||||
|
||||
/**
|
||||
* Compound storage pooling: the hub's totals cover itself plus its operational
|
||||
* children. Building or awaiting-materials children contribute nothing yet. An
|
||||
* uncapped member (null capacity) makes the whole compound uncapped.
|
||||
*/
|
||||
export function aggregateCompoundStorage(
|
||||
hub: CompoundMemberLike,
|
||||
children: CompoundMemberLike[],
|
||||
): { storageUsedKg: number; storageCapacityKg: number | null } {
|
||||
let used = hub.storageUsedKg ?? 0;
|
||||
let capacity: number | null = hub.storageCapacityKg ?? null;
|
||||
for (const child of children) {
|
||||
if (!isOperationalMember(child)) continue;
|
||||
used += child.storageUsedKg ?? 0;
|
||||
const childCap = child.storageCapacityKg;
|
||||
if (childCap === null || childCap === undefined) capacity = null;
|
||||
else if (capacity !== null) capacity += childCap;
|
||||
}
|
||||
return { storageUsedKg: used, storageCapacityKg: capacity };
|
||||
}
|
||||
28612
src/migrations/20260918_052751_add_production_system.json
Normal file
28612
src/migrations/20260918_052751_add_production_system.json
Normal file
File diff suppressed because it is too large
Load diff
84
src/migrations/20260918_052751_add_production_system.ts
Normal file
84
src/migrations/20260918_052751_add_production_system.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
|
||||
|
||||
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
ALTER TYPE "public"."enum_map_zones_type" ADD VALUE IF NOT EXISTS 'territory';
|
||||
ALTER TYPE "public"."enum_map_zones_type" ADD VALUE IF NOT EXISTS 'border';
|
||||
ALTER TYPE "public"."enum_map_zones_type" ADD VALUE IF NOT EXISTS 'other';
|
||||
CREATE TABLE "game_structures_production_inputs" (
|
||||
"_order" integer NOT NULL,
|
||||
"_parent_id" integer NOT NULL,
|
||||
"id" varchar PRIMARY KEY NOT NULL,
|
||||
"resource_id" integer NOT NULL,
|
||||
"amount_per_hour" numeric DEFAULT 0 NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE "game_structures_production_outputs" (
|
||||
"_order" integer NOT NULL,
|
||||
"_parent_id" integer NOT NULL,
|
||||
"id" varchar PRIMARY KEY NOT NULL,
|
||||
"resource_id" integer NOT NULL,
|
||||
"amount_per_hour" numeric DEFAULT 0 NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE "structures_production_inputs" (
|
||||
"_order" integer NOT NULL,
|
||||
"_parent_id" integer NOT NULL,
|
||||
"id" varchar PRIMARY KEY NOT NULL,
|
||||
"resource_id" integer NOT NULL,
|
||||
"amount_per_hour" numeric DEFAULT 0 NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE "structures_production_outputs" (
|
||||
"_order" integer NOT NULL,
|
||||
"_parent_id" integer NOT NULL,
|
||||
"id" varchar PRIMARY KEY NOT NULL,
|
||||
"resource_id" integer NOT NULL,
|
||||
"amount_per_hour" numeric DEFAULT 0 NOT NULL
|
||||
);
|
||||
|
||||
ALTER TABLE "map_roads" ALTER COLUMN "label_visible" DROP DEFAULT;
|
||||
ALTER TABLE "game_structures" ADD COLUMN "production_paused" boolean DEFAULT false;
|
||||
ALTER TABLE "game_structures" ADD COLUMN "last_production_at" timestamp(3) with time zone;
|
||||
ALTER TABLE "game_structures" ADD COLUMN "production_stall_reason" varchar;
|
||||
ALTER TABLE "game_structures_production_inputs" ADD CONSTRAINT "game_structures_production_inputs_resource_id_resources_id_fk" FOREIGN KEY ("resource_id") REFERENCES "public"."resources"("id") ON DELETE set null ON UPDATE no action;
|
||||
ALTER TABLE "game_structures_production_inputs" ADD CONSTRAINT "game_structures_production_inputs_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."game_structures"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "game_structures_production_outputs" ADD CONSTRAINT "game_structures_production_outputs_resource_id_resources_id_fk" FOREIGN KEY ("resource_id") REFERENCES "public"."resources"("id") ON DELETE set null ON UPDATE no action;
|
||||
ALTER TABLE "game_structures_production_outputs" ADD CONSTRAINT "game_structures_production_outputs_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."game_structures"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "structures_production_inputs" ADD CONSTRAINT "structures_production_inputs_resource_id_resources_id_fk" FOREIGN KEY ("resource_id") REFERENCES "public"."resources"("id") ON DELETE set null ON UPDATE no action;
|
||||
ALTER TABLE "structures_production_inputs" ADD CONSTRAINT "structures_production_inputs_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."structures"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "structures_production_outputs" ADD CONSTRAINT "structures_production_outputs_resource_id_resources_id_fk" FOREIGN KEY ("resource_id") REFERENCES "public"."resources"("id") ON DELETE set null ON UPDATE no action;
|
||||
ALTER TABLE "structures_production_outputs" ADD CONSTRAINT "structures_production_outputs_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."structures"("id") ON DELETE cascade ON UPDATE no action;
|
||||
CREATE INDEX "game_structures_production_inputs_order_idx" ON "game_structures_production_inputs" USING btree ("_order");
|
||||
CREATE INDEX "game_structures_production_inputs_parent_id_idx" ON "game_structures_production_inputs" USING btree ("_parent_id");
|
||||
CREATE INDEX "game_structures_production_inputs_resource_idx" ON "game_structures_production_inputs" USING btree ("resource_id");
|
||||
CREATE INDEX "game_structures_production_outputs_order_idx" ON "game_structures_production_outputs" USING btree ("_order");
|
||||
CREATE INDEX "game_structures_production_outputs_parent_id_idx" ON "game_structures_production_outputs" USING btree ("_parent_id");
|
||||
CREATE INDEX "game_structures_production_outputs_resource_idx" ON "game_structures_production_outputs" USING btree ("resource_id");
|
||||
CREATE INDEX "structures_production_inputs_order_idx" ON "structures_production_inputs" USING btree ("_order");
|
||||
CREATE INDEX "structures_production_inputs_parent_id_idx" ON "structures_production_inputs" USING btree ("_parent_id");
|
||||
CREATE INDEX "structures_production_inputs_resource_idx" ON "structures_production_inputs" USING btree ("resource_id");
|
||||
CREATE INDEX "structures_production_outputs_order_idx" ON "structures_production_outputs" USING btree ("_order");
|
||||
CREATE INDEX "structures_production_outputs_parent_id_idx" ON "structures_production_outputs" USING btree ("_parent_id");
|
||||
CREATE INDEX "structures_production_outputs_resource_idx" ON "structures_production_outputs" USING btree ("resource_id");`)
|
||||
}
|
||||
|
||||
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
ALTER TABLE "game_structures_production_inputs" DISABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE "game_structures_production_outputs" DISABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE "structures_production_inputs" DISABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE "structures_production_outputs" DISABLE ROW LEVEL SECURITY;
|
||||
DROP TABLE "game_structures_production_inputs" CASCADE;
|
||||
DROP TABLE "game_structures_production_outputs" CASCADE;
|
||||
DROP TABLE "structures_production_inputs" CASCADE;
|
||||
DROP TABLE "structures_production_outputs" CASCADE;
|
||||
ALTER TABLE "map_zones" ALTER COLUMN "type" SET DATA TYPE text;
|
||||
DROP TYPE "public"."enum_map_zones_type";
|
||||
CREATE TYPE "public"."enum_map_zones_type" AS ENUM('water', 'land');
|
||||
ALTER TABLE "map_zones" ALTER COLUMN "type" SET DATA TYPE "public"."enum_map_zones_type" USING "type"::"public"."enum_map_zones_type";
|
||||
ALTER TABLE "map_roads" ALTER COLUMN "label_visible" SET DEFAULT true;
|
||||
ALTER TABLE "game_structures" DROP COLUMN "production_paused";
|
||||
ALTER TABLE "game_structures" DROP COLUMN "last_production_at";
|
||||
ALTER TABLE "game_structures" DROP COLUMN "production_stall_reason";`)
|
||||
}
|
||||
28626
src/migrations/20260918_054722_add_labor_construction_speed.json
Normal file
28626
src/migrations/20260918_054722_add_labor_construction_speed.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,13 @@
|
|||
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
|
||||
|
||||
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
ALTER TABLE "labor_classifications" ADD COLUMN "build_efficiency" numeric DEFAULT 1;
|
||||
ALTER TABLE "structures" ADD COLUMN "construction_crew_required" numeric DEFAULT 0;`)
|
||||
}
|
||||
|
||||
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
ALTER TABLE "labor_classifications" DROP COLUMN "build_efficiency";
|
||||
ALTER TABLE "structures" DROP COLUMN "construction_crew_required";`)
|
||||
}
|
||||
29041
src/migrations/20260918_061857_add_module_slots.json
Normal file
29041
src/migrations/20260918_061857_add_module_slots.json
Normal file
File diff suppressed because it is too large
Load diff
66
src/migrations/20260918_061857_add_module_slots.ts
Normal file
66
src/migrations/20260918_061857_add_module_slots.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
|
||||
|
||||
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
CREATE TYPE "public"."enum_structures_module_options_effects_type" AS ENUM('capacity_mass', 'production_multiplier', 'storage_slots');
|
||||
CREATE TYPE "public"."enum_structures_module_options_category" AS ENUM('production', 'logistics', 'defense', 'utility');
|
||||
CREATE TABLE "game_structures_installed_modules" (
|
||||
"_order" integer NOT NULL,
|
||||
"_parent_id" integer NOT NULL,
|
||||
"id" varchar PRIMARY KEY NOT NULL,
|
||||
"name" varchar NOT NULL,
|
||||
"installed_at" timestamp(3) with time zone
|
||||
);
|
||||
|
||||
CREATE TABLE "structures_module_options_effects" (
|
||||
"_order" integer NOT NULL,
|
||||
"_parent_id" varchar NOT NULL,
|
||||
"id" varchar PRIMARY KEY NOT NULL,
|
||||
"type" "enum_structures_module_options_effects_type" NOT NULL,
|
||||
"value" numeric NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE "structures_module_options_materials" (
|
||||
"_order" integer NOT NULL,
|
||||
"_parent_id" varchar NOT NULL,
|
||||
"id" varchar PRIMARY KEY NOT NULL,
|
||||
"resource_id" integer NOT NULL,
|
||||
"amount" numeric NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE "structures_module_options" (
|
||||
"_order" integer NOT NULL,
|
||||
"_parent_id" integer NOT NULL,
|
||||
"id" varchar PRIMARY KEY NOT NULL,
|
||||
"name" varchar NOT NULL,
|
||||
"category" "enum_structures_module_options_category" DEFAULT 'utility' NOT NULL,
|
||||
"description" varchar
|
||||
);
|
||||
|
||||
ALTER TABLE "structures" ADD COLUMN "module_slots" numeric DEFAULT 0;
|
||||
ALTER TABLE "game_structures_installed_modules" ADD CONSTRAINT "game_structures_installed_modules_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."game_structures"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "structures_module_options_effects" ADD CONSTRAINT "structures_module_options_effects_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."structures_module_options"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "structures_module_options_materials" ADD CONSTRAINT "structures_module_options_materials_resource_id_resources_id_fk" FOREIGN KEY ("resource_id") REFERENCES "public"."resources"("id") ON DELETE set null ON UPDATE no action;
|
||||
ALTER TABLE "structures_module_options_materials" ADD CONSTRAINT "structures_module_options_materials_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."structures_module_options"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "structures_module_options" ADD CONSTRAINT "structures_module_options_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."structures"("id") ON DELETE cascade ON UPDATE no action;
|
||||
CREATE INDEX "game_structures_installed_modules_order_idx" ON "game_structures_installed_modules" USING btree ("_order");
|
||||
CREATE INDEX "game_structures_installed_modules_parent_id_idx" ON "game_structures_installed_modules" USING btree ("_parent_id");
|
||||
CREATE INDEX "structures_module_options_effects_order_idx" ON "structures_module_options_effects" USING btree ("_order");
|
||||
CREATE INDEX "structures_module_options_effects_parent_id_idx" ON "structures_module_options_effects" USING btree ("_parent_id");
|
||||
CREATE INDEX "structures_module_options_materials_order_idx" ON "structures_module_options_materials" USING btree ("_order");
|
||||
CREATE INDEX "structures_module_options_materials_parent_id_idx" ON "structures_module_options_materials" USING btree ("_parent_id");
|
||||
CREATE INDEX "structures_module_options_materials_resource_idx" ON "structures_module_options_materials" USING btree ("resource_id");
|
||||
CREATE INDEX "structures_module_options_order_idx" ON "structures_module_options" USING btree ("_order");
|
||||
CREATE INDEX "structures_module_options_parent_id_idx" ON "structures_module_options" USING btree ("_parent_id");`)
|
||||
}
|
||||
|
||||
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
DROP TABLE "game_structures_installed_modules" CASCADE;
|
||||
DROP TABLE "structures_module_options_effects" CASCADE;
|
||||
DROP TABLE "structures_module_options_materials" CASCADE;
|
||||
DROP TABLE "structures_module_options" CASCADE;
|
||||
ALTER TABLE "structures" DROP COLUMN "module_slots";
|
||||
DROP TYPE "public"."enum_structures_module_options_effects_type";
|
||||
DROP TYPE "public"."enum_structures_module_options_category";`)
|
||||
}
|
||||
29075
src/migrations/20260918_064326_add_compound_parent.json
Normal file
29075
src/migrations/20260918_064326_add_compound_parent.json
Normal file
File diff suppressed because it is too large
Load diff
16
src/migrations/20260918_064326_add_compound_parent.ts
Normal file
16
src/migrations/20260918_064326_add_compound_parent.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
|
||||
|
||||
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
ALTER TABLE "game_structures" ADD COLUMN "parent_structure_id" integer;
|
||||
ALTER TABLE "game_structures" ADD CONSTRAINT "game_structures_parent_structure_id_game_structures_id_fk" FOREIGN KEY ("parent_structure_id") REFERENCES "public"."game_structures"("id") ON DELETE set null ON UPDATE no action;
|
||||
CREATE INDEX "game_structures_parent_structure_idx" ON "game_structures" USING btree ("parent_structure_id");`)
|
||||
}
|
||||
|
||||
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
ALTER TABLE "game_structures" DROP CONSTRAINT "game_structures_parent_structure_id_game_structures_id_fk";
|
||||
|
||||
DROP INDEX "game_structures_parent_structure_idx";
|
||||
ALTER TABLE "game_structures" DROP COLUMN "parent_structure_id";`)
|
||||
}
|
||||
29103
src/migrations/20260918_070328_add_blueprint_aura.json
Normal file
29103
src/migrations/20260918_070328_add_blueprint_aura.json
Normal file
File diff suppressed because it is too large
Load diff
17
src/migrations/20260918_070328_add_blueprint_aura.ts
Normal file
17
src/migrations/20260918_070328_add_blueprint_aura.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
|
||||
|
||||
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
CREATE TYPE "public"."enum_structures_aura_effect_type" AS ENUM('production_multiplier', 'capacity_mass');
|
||||
ALTER TABLE "structures" ADD COLUMN "aura_effect_type" "enum_structures_aura_effect_type";
|
||||
ALTER TABLE "structures" ADD COLUMN "aura_value" numeric;
|
||||
ALTER TABLE "structures" ADD COLUMN "aura_range_meters" numeric DEFAULT 0;`)
|
||||
}
|
||||
|
||||
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
ALTER TABLE "structures" DROP COLUMN "aura_effect_type";
|
||||
ALTER TABLE "structures" DROP COLUMN "aura_value";
|
||||
ALTER TABLE "structures" DROP COLUMN "aura_range_meters";
|
||||
DROP TYPE "public"."enum_structures_aura_effect_type";`)
|
||||
}
|
||||
303
tests/int/adjacency.int.spec.ts
Normal file
303
tests/int/adjacency.int.spec.ts
Normal file
|
|
@ -0,0 +1,303 @@
|
|||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { getPayload, type CollectionSlug, type Payload, type Where } from "payload";
|
||||
import type { Map as MapDoc, Resource, Structure } from "@/payload-types";
|
||||
|
||||
import config from "@payload-config";
|
||||
import dotenv from "dotenv";
|
||||
import { auraBonusAt, type AuraSourceLike } from "@/lib/base/adjacency";
|
||||
import { processProductionTick } from "@/lib/base/production";
|
||||
|
||||
dotenv.config({ path: ".env" });
|
||||
|
||||
const RUN = `aura-${Date.now()}`;
|
||||
const LEXICAL_EMPTY = {
|
||||
root: {
|
||||
children: [{ text: "" }],
|
||||
direction: null,
|
||||
format: "" as const,
|
||||
indent: 0,
|
||||
type: "text",
|
||||
version: 1,
|
||||
},
|
||||
};
|
||||
|
||||
let payload: Payload;
|
||||
|
||||
const createdIds = {
|
||||
maps: [] as number[],
|
||||
resources: [] as number[],
|
||||
structureBlueprints: [] as number[],
|
||||
gameStructures: [] as number[],
|
||||
classifications: [] as number[],
|
||||
};
|
||||
|
||||
async function makeResource(name: string, massPerUnit = 1): Promise<Resource> {
|
||||
return (await payload.create({
|
||||
collection: "resources",
|
||||
data: {
|
||||
name,
|
||||
codeName: `${RUN}-${name.toLowerCase().replace(/\s+/g, "-")}`,
|
||||
approvalStatus: "in_progress",
|
||||
type: "physical",
|
||||
baseValue: 1,
|
||||
rarity: "common",
|
||||
unitOfMeasure: "kg",
|
||||
massPerUnit,
|
||||
gridWidth: 1,
|
||||
gridHeight: 1,
|
||||
},
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
})) as unknown as Resource;
|
||||
}
|
||||
|
||||
async function makeBlueprint(data: {
|
||||
name: string;
|
||||
materialResource: number;
|
||||
outputs?: { resource: number; amountPerHour: number }[];
|
||||
maxCapacityMass?: number;
|
||||
aura?: {
|
||||
effectType: "production_multiplier" | "capacity_mass";
|
||||
value: number;
|
||||
rangeMeters: number;
|
||||
};
|
||||
}): Promise<Structure> {
|
||||
return (await payload.create({
|
||||
collection: "structures",
|
||||
data: {
|
||||
name: data.name,
|
||||
codeName: data.name.toLowerCase().replace(/\s+/g, "_"),
|
||||
approvalStatus: "in_progress",
|
||||
description: LEXICAL_EMPTY as unknown as Structure["description"],
|
||||
category: "logistics",
|
||||
materials: [{ resource: data.materialResource, amount: 1 }],
|
||||
constructionDurationMinutes: 1,
|
||||
terrainType: "land",
|
||||
maxHealth: 100,
|
||||
maxCapacityMass: data.maxCapacityMass,
|
||||
productionOutputs: data.outputs,
|
||||
...(data.aura ? { aura: data.aura } : {}),
|
||||
},
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
})) as unknown as Structure;
|
||||
}
|
||||
|
||||
async function makeSite(data: {
|
||||
name: string;
|
||||
blueprintId: number;
|
||||
materialResource: number;
|
||||
point?: [number, number];
|
||||
constructionStatus?: "awaiting_materials" | "building" | "complete";
|
||||
}): Promise<{ id: number }> {
|
||||
return (await payload.create({
|
||||
collection: "game-structures",
|
||||
data: {
|
||||
name: data.name,
|
||||
type: data.blueprintId,
|
||||
map: createdIds.maps[0],
|
||||
coordinates: data.point ?? [3000, 3000],
|
||||
constructionStatus: data.constructionStatus ?? "complete",
|
||||
storedResources: [{ resource: data.materialResource, amount: 10, gridX: 0, gridY: 0 }],
|
||||
},
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
})) as unknown as { id: number };
|
||||
}
|
||||
|
||||
async function assignLabor(siteId: number): Promise<void> {
|
||||
const classification = (await payload.create({
|
||||
collection: "labor-classifications",
|
||||
data: {
|
||||
name: `${RUN} Crew ${siteId}`,
|
||||
key: `${RUN}-crew-${siteId}`,
|
||||
defaultUnitSalary: 1,
|
||||
availableHeadcount: 50,
|
||||
},
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
})) as unknown as { id: number };
|
||||
createdIds.classifications.push(classification.id);
|
||||
await payload.create({
|
||||
collection: "npc-staffing",
|
||||
data: {
|
||||
structure: siteId,
|
||||
positionType: "other",
|
||||
headcount: 5,
|
||||
laborClassification: classification.id,
|
||||
positionTitle: "Labor crew",
|
||||
status: "active",
|
||||
},
|
||||
overrideAccess: true,
|
||||
});
|
||||
}
|
||||
|
||||
async function voidAmount(siteId: number, resourceId: number): Promise<number> {
|
||||
const doc = (await payload.findByID({
|
||||
collection: "game-structures",
|
||||
id: siteId,
|
||||
depth: 1,
|
||||
overrideAccess: true,
|
||||
})) as unknown as { voidStorage: { resource: number | Resource; amount: number }[] };
|
||||
return doc.voidStorage.reduce(
|
||||
(total, entry) =>
|
||||
total +
|
||||
((typeof entry.resource === "object" ? entry.resource.id : entry.resource) === resourceId
|
||||
? entry.amount
|
||||
: 0),
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
describe("Adjacency auras", () => {
|
||||
describe("pure aura math", () => {
|
||||
const source = (over: Partial<AuraSourceLike> & { id: number }): AuraSourceLike => ({
|
||||
coordinates: [5000, 5000],
|
||||
constructionStatus: "complete",
|
||||
blueprint: { aura: { effectType: "production_multiplier", value: 0.5, rangeMeters: 2000 } },
|
||||
...over,
|
||||
});
|
||||
const target = { id: 99, coordinates: [6000, 5000] as [number, number] };
|
||||
|
||||
it("sums in-range auras and ignores out-of-range, inactive, and self sources", () => {
|
||||
expect(
|
||||
auraBonusAt(target, [
|
||||
source({ id: 1 }),
|
||||
source({ id: 2 }),
|
||||
source({ id: 3, coordinates: [50000, 50000] }),
|
||||
source({ id: 4, constructionStatus: "building" }),
|
||||
source({
|
||||
id: 5,
|
||||
blueprint: { aura: { effectType: "capacity_mass", value: 300, rangeMeters: 2000 } },
|
||||
}),
|
||||
]),
|
||||
).toEqual({ productionMultiplier: 1, capacityMass: 300 });
|
||||
expect(auraBonusAt(target, [source({ id: 1 }), source({ id: 99 })])).toEqual({
|
||||
productionMultiplier: 0.5,
|
||||
capacityMass: 0,
|
||||
});
|
||||
expect(auraBonusAt({ id: 7, coordinates: null }, [source({ id: 1 })])).toEqual({
|
||||
productionMultiplier: 0,
|
||||
capacityMass: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("production tick", () => {
|
||||
beforeAll(async () => {
|
||||
payload = await getPayload({ config });
|
||||
createdIds.maps.push(
|
||||
(
|
||||
(await payload.create({
|
||||
collection: "maps",
|
||||
data: {
|
||||
name: `${RUN} map`,
|
||||
worldSizeWidth: 8192,
|
||||
worldSizeHeight: 8192,
|
||||
basemapMode: "image",
|
||||
},
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
})) as unknown as MapDoc
|
||||
).id,
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
const sweep = async (collection: CollectionSlug, where: Where) => {
|
||||
const found = await payload
|
||||
.find({ collection, where, limit: 500, depth: 0, overrideAccess: true })
|
||||
.catch(() => null);
|
||||
for (const doc of found?.docs ?? []) {
|
||||
await payload.delete({ collection, id: doc.id, overrideAccess: true }).catch(() => {});
|
||||
}
|
||||
};
|
||||
await sweep("npc-staffing", { structure: { in: createdIds.gameStructures } });
|
||||
await sweep("game-event-logs", { structure: { in: createdIds.gameStructures } });
|
||||
await sweep("game-structures", { name: { like: `${RUN}%` } });
|
||||
await sweep("structures", { name: { like: `${RUN}%` } });
|
||||
await sweep("labor-classifications", { name: { like: `${RUN}%` } });
|
||||
await sweep("resources", { name: { like: `${RUN}%` } });
|
||||
await sweep("maps", { name: { like: `${RUN}%` } });
|
||||
});
|
||||
|
||||
it("boosts a neighbor's output with a production aura", async () => {
|
||||
const timber = await makeResource("Logs");
|
||||
const fuel = await makeResource("Ethanol");
|
||||
createdIds.resources.push(timber.id, fuel.id);
|
||||
const producer = await makeBlueprint({
|
||||
name: `${RUN} Boiler`,
|
||||
materialResource: timber.id,
|
||||
outputs: [{ resource: fuel.id, amountPerHour: 1000 }],
|
||||
});
|
||||
const auraTower = await makeBlueprint({
|
||||
name: `${RUN} Pylon`,
|
||||
materialResource: timber.id,
|
||||
aura: { effectType: "production_multiplier", value: 0.5, rangeMeters: 2000 },
|
||||
});
|
||||
createdIds.structureBlueprints.push(producer.id, auraTower.id);
|
||||
const site = await makeSite({
|
||||
name: `${RUN} Boiler site`,
|
||||
blueprintId: producer.id,
|
||||
materialResource: timber.id,
|
||||
point: [4000, 3000],
|
||||
});
|
||||
createdIds.gameStructures.push(site.id);
|
||||
const tower = await makeSite({
|
||||
name: `${RUN} Pylon site`,
|
||||
blueprintId: auraTower.id,
|
||||
materialResource: timber.id,
|
||||
point: [3000, 3000],
|
||||
});
|
||||
createdIds.gameStructures.push(tower.id);
|
||||
await assignLabor(site.id);
|
||||
|
||||
const start = new Date();
|
||||
await processProductionTick(payload, undefined, start, [site.id]);
|
||||
await processProductionTick(payload, undefined, new Date(start.getTime() + 3_600_000), [
|
||||
site.id,
|
||||
]);
|
||||
await expect(voidAmount(site.id, fuel.id)).resolves.toBe(1500);
|
||||
});
|
||||
|
||||
it("raises a neighbor's production capacity with a capacity aura", async () => {
|
||||
const timber = await makeResource("Sticks");
|
||||
const fuel = await makeResource("Biodiesel");
|
||||
createdIds.resources.push(timber.id, fuel.id);
|
||||
const producer = await makeBlueprint({
|
||||
name: `${RUN} Still`,
|
||||
materialResource: timber.id,
|
||||
outputs: [{ resource: fuel.id, amountPerHour: 1000 }],
|
||||
maxCapacityMass: 100,
|
||||
});
|
||||
const auraTower = await makeBlueprint({
|
||||
name: `${RUN} Silo Pylon`,
|
||||
materialResource: timber.id,
|
||||
aura: { effectType: "capacity_mass", value: 500, rangeMeters: 2000 },
|
||||
});
|
||||
createdIds.structureBlueprints.push(producer.id, auraTower.id);
|
||||
const site = await makeSite({
|
||||
name: `${RUN} Still site`,
|
||||
blueprintId: producer.id,
|
||||
materialResource: timber.id,
|
||||
point: [4000, 3000],
|
||||
});
|
||||
createdIds.gameStructures.push(site.id);
|
||||
const tower = await makeSite({
|
||||
name: `${RUN} Silo Pylon site`,
|
||||
blueprintId: auraTower.id,
|
||||
materialResource: timber.id,
|
||||
point: [3000, 3000],
|
||||
});
|
||||
createdIds.gameStructures.push(tower.id);
|
||||
await assignLabor(site.id);
|
||||
|
||||
const start = new Date();
|
||||
await processProductionTick(payload, undefined, start, [site.id]);
|
||||
await processProductionTick(payload, undefined, new Date(start.getTime() + 3_600_000), [
|
||||
site.id,
|
||||
]);
|
||||
await expect(voidAmount(site.id, fuel.id)).resolves.toBe(600);
|
||||
});
|
||||
});
|
||||
});
|
||||
364
tests/int/construction-speed.int.spec.ts
Normal file
364
tests/int/construction-speed.int.spec.ts
Normal file
|
|
@ -0,0 +1,364 @@
|
|||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { getPayload, type CollectionSlug, type Payload, type Where } from "payload";
|
||||
import type { Map as MapDoc, Resource, Structure } from "@/payload-types";
|
||||
|
||||
import config from "@payload-config";
|
||||
import dotenv from "dotenv";
|
||||
import { setLaborHeadcount as setLaborHeadcountService } from "@/lib/base";
|
||||
import {
|
||||
MIN_BUILD_SPEED_FACTOR,
|
||||
buildSpeedFactor,
|
||||
durationMinutesFor,
|
||||
effectiveCrew,
|
||||
recomputeCompletesAt,
|
||||
} from "@/lib/base/constructionSpeed";
|
||||
import { maybeStartConstruction } from "@/lib/map/construction";
|
||||
|
||||
dotenv.config({ path: ".env" });
|
||||
|
||||
const RUN = `cspeed-${Date.now()}`;
|
||||
const LEXICAL_EMPTY = {
|
||||
root: {
|
||||
children: [{ text: "" }],
|
||||
direction: null,
|
||||
format: "" as const,
|
||||
indent: 0,
|
||||
type: "text",
|
||||
version: 1,
|
||||
},
|
||||
};
|
||||
|
||||
let payload: Payload;
|
||||
let classificationCounter = 0;
|
||||
let hiringWasEnabled = false;
|
||||
|
||||
const createdIds = {
|
||||
maps: [] as number[],
|
||||
resources: [] as number[],
|
||||
structureBlueprints: [] as number[],
|
||||
gameStructures: [] as number[],
|
||||
classifications: [] as number[],
|
||||
};
|
||||
|
||||
async function makeResource(name: string): Promise<Resource> {
|
||||
return (await payload.create({
|
||||
collection: "resources",
|
||||
data: {
|
||||
name,
|
||||
codeName: `${RUN}-${name.toLowerCase().replace(/\s+/g, "-")}`,
|
||||
approvalStatus: "in_progress",
|
||||
type: "physical",
|
||||
baseValue: 1,
|
||||
rarity: "common",
|
||||
unitOfMeasure: "kg",
|
||||
massPerUnit: 1,
|
||||
gridWidth: 1,
|
||||
gridHeight: 1,
|
||||
},
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
})) as unknown as Resource;
|
||||
}
|
||||
|
||||
async function makeBlueprint(data: {
|
||||
name: string;
|
||||
materialResource: number;
|
||||
durationMinutes: number;
|
||||
crewRequired: number;
|
||||
laborRequirements?: {
|
||||
classification: number;
|
||||
requiredHeadcount: number;
|
||||
maxHeadcount: number;
|
||||
}[];
|
||||
}): Promise<Structure> {
|
||||
return (await payload.create({
|
||||
collection: "structures",
|
||||
data: {
|
||||
name: data.name,
|
||||
codeName: data.name.toLowerCase().replace(/\s+/g, "_"),
|
||||
approvalStatus: "in_progress",
|
||||
description: LEXICAL_EMPTY as unknown as Structure["description"],
|
||||
category: "logistics",
|
||||
materials: [{ resource: data.materialResource, amount: 1 }],
|
||||
constructionDurationMinutes: data.durationMinutes,
|
||||
constructionCrewRequired: data.crewRequired,
|
||||
terrainType: "land",
|
||||
maxHealth: 100,
|
||||
laborRequirements: data.laborRequirements,
|
||||
},
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
})) as unknown as Structure;
|
||||
}
|
||||
|
||||
async function makeSite(data: {
|
||||
name: string;
|
||||
blueprintId: number;
|
||||
materialResource: number;
|
||||
}): Promise<{ id: number }> {
|
||||
return (await payload.create({
|
||||
collection: "game-structures",
|
||||
data: {
|
||||
name: data.name,
|
||||
type: data.blueprintId,
|
||||
map: createdIds.maps[0],
|
||||
coordinates: [500, 500],
|
||||
constructionStatus: "awaiting_materials",
|
||||
storedResources: [{ resource: data.materialResource, amount: 10, gridX: 0, gridY: 0 }],
|
||||
},
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
})) as unknown as { id: number };
|
||||
}
|
||||
|
||||
async function makeClassification(buildEfficiency = 1): Promise<{ id: number }> {
|
||||
classificationCounter += 1;
|
||||
return (await payload.create({
|
||||
collection: "labor-classifications",
|
||||
data: {
|
||||
name: `${RUN} Crew ${classificationCounter}`,
|
||||
key: `${RUN}-crew-${classificationCounter}`,
|
||||
defaultUnitSalary: 1,
|
||||
buildEfficiency,
|
||||
availableHeadcount: 50,
|
||||
},
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
})) as unknown as { id: number };
|
||||
}
|
||||
|
||||
async function siteConstruction(siteId: number): Promise<{
|
||||
constructionStatus: string | null;
|
||||
startedAt: string | null;
|
||||
completesAt: string | null;
|
||||
}> {
|
||||
const doc = (await payload.findByID({
|
||||
collection: "game-structures",
|
||||
id: siteId,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})) as unknown as {
|
||||
constructionStatus: string | null;
|
||||
constructionStartedAt: string | null;
|
||||
constructionCompletesAt: string | null;
|
||||
};
|
||||
return {
|
||||
constructionStatus: doc.constructionStatus,
|
||||
startedAt: doc.constructionStartedAt,
|
||||
completesAt: doc.constructionCompletesAt,
|
||||
};
|
||||
}
|
||||
|
||||
describe("Construction crew speed", () => {
|
||||
describe("pure math", () => {
|
||||
const rows = [
|
||||
{ classificationId: 1, headcount: 3, efficiency: 1 },
|
||||
{ classificationId: 2, headcount: 2, efficiency: 0.5 },
|
||||
];
|
||||
|
||||
it("scores crews and clamps the speed factor", () => {
|
||||
expect(effectiveCrew(rows)).toBe(4);
|
||||
expect(buildSpeedFactor(rows, 0)).toBe(1);
|
||||
expect(buildSpeedFactor(rows, 8)).toBe(0.5);
|
||||
expect(buildSpeedFactor(rows, 4)).toBe(1);
|
||||
expect(buildSpeedFactor([], 6)).toBe(0);
|
||||
});
|
||||
|
||||
it("floors the derived duration so a stripped site never freezes", () => {
|
||||
expect(durationMinutesFor(60, 1)).toBe(60);
|
||||
expect(durationMinutesFor(60, 0.5)).toBe(120);
|
||||
expect(durationMinutesFor(60, 0)).toBe(60 / MIN_BUILD_SPEED_FACTOR);
|
||||
});
|
||||
|
||||
it("recomputes completesAt from the finished fraction", () => {
|
||||
const start = new Date("2026-01-01T00:00:00Z");
|
||||
const end = new Date(start.getTime() + 60 * 60_000);
|
||||
const now = new Date(start.getTime() + 30 * 60_000);
|
||||
// Half done at factor 1, re-priced at 0.5: 30 base minutes left -> 60 wall minutes.
|
||||
expect(
|
||||
recomputeCompletesAt({
|
||||
startedAt: start,
|
||||
completesAt: end,
|
||||
baseMinutes: 60,
|
||||
oldFactor: 1,
|
||||
newFactor: 0.5,
|
||||
now,
|
||||
}),
|
||||
).toBe(new Date(now.getTime() + 60 * 60_000).toISOString());
|
||||
// Half done at factor 0.5, re-priced at 1: 15 base minutes left -> 15 wall minutes.
|
||||
expect(
|
||||
recomputeCompletesAt({
|
||||
startedAt: start,
|
||||
completesAt: end,
|
||||
baseMinutes: 60,
|
||||
oldFactor: 0.5,
|
||||
newFactor: 1,
|
||||
now,
|
||||
}),
|
||||
).toBe(new Date(now.getTime() + 15 * 60_000).toISOString());
|
||||
// Already past the deadline: sweeper territory, no recompute.
|
||||
expect(
|
||||
recomputeCompletesAt({
|
||||
startedAt: start,
|
||||
completesAt: end,
|
||||
baseMinutes: 60,
|
||||
oldFactor: 1,
|
||||
newFactor: 1,
|
||||
now: end,
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("start gating and rate recompute", () => {
|
||||
beforeAll(async () => {
|
||||
payload = await getPayload({ config });
|
||||
const rules = (await payload.findGlobal({
|
||||
slug: "game-rules",
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})) as unknown as { staffHiringEnabled?: boolean };
|
||||
hiringWasEnabled = rules.staffHiringEnabled === true;
|
||||
if (!hiringWasEnabled) {
|
||||
await payload.updateGlobal({
|
||||
slug: "game-rules",
|
||||
data: { staffHiringEnabled: true },
|
||||
overrideAccess: true,
|
||||
});
|
||||
}
|
||||
createdIds.maps.push(
|
||||
(
|
||||
(await payload.create({
|
||||
collection: "maps",
|
||||
data: {
|
||||
name: `${RUN} map`,
|
||||
worldSizeWidth: 8192,
|
||||
worldSizeHeight: 8192,
|
||||
basemapMode: "image",
|
||||
},
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
})) as unknown as MapDoc
|
||||
).id,
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
const sweep = async (collection: CollectionSlug, where: Where) => {
|
||||
const found = await payload
|
||||
.find({ collection, where, limit: 500, depth: 0, overrideAccess: true })
|
||||
.catch(() => null);
|
||||
for (const doc of found?.docs ?? []) {
|
||||
await payload.delete({ collection, id: doc.id, overrideAccess: true }).catch(() => {});
|
||||
}
|
||||
};
|
||||
await sweep("npc-staffing", { structure: { in: createdIds.gameStructures } });
|
||||
await sweep("game-event-logs", { structure: { in: createdIds.gameStructures } });
|
||||
await sweep("game-structures", { name: { like: `${RUN}%` } });
|
||||
await sweep("structures", { name: { like: `${RUN}%` } });
|
||||
await sweep("labor-classifications", { name: { like: `${RUN}%` } });
|
||||
await sweep("resources", { name: { like: `${RUN}%` } });
|
||||
await sweep("maps", { name: { like: `${RUN}%` } });
|
||||
if (!hiringWasEnabled) {
|
||||
await payload.updateGlobal({
|
||||
slug: "game-rules",
|
||||
data: { staffHiringEnabled: false },
|
||||
overrideAccess: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses to start a crewed build with no labor assigned", async () => {
|
||||
const timber = await makeResource("Timber");
|
||||
createdIds.resources.push(timber.id);
|
||||
const blueprint = await makeBlueprint({
|
||||
name: `${RUN} Watchtower`,
|
||||
materialResource: timber.id,
|
||||
durationMinutes: 60,
|
||||
crewRequired: 6,
|
||||
});
|
||||
createdIds.structureBlueprints.push(blueprint.id);
|
||||
const site = await makeSite({
|
||||
name: `${RUN} Watchtower site`,
|
||||
blueprintId: blueprint.id,
|
||||
materialResource: timber.id,
|
||||
});
|
||||
createdIds.gameStructures.push(site.id);
|
||||
|
||||
const result = await maybeStartConstruction(payload, site.id);
|
||||
expect(result.started).toBe(false);
|
||||
expect(result.reason).toBe("no_crew");
|
||||
expect((await siteConstruction(site.id)).constructionStatus).toBe("awaiting_materials");
|
||||
});
|
||||
|
||||
it("scales the build duration by the staffed crew and re-prices on headcount change", async () => {
|
||||
const timber = await makeResource("Lumber");
|
||||
createdIds.resources.push(timber.id);
|
||||
const crew = await makeClassification(1);
|
||||
createdIds.classifications.push(crew.id);
|
||||
const blueprint = await makeBlueprint({
|
||||
name: `${RUN} Depot`,
|
||||
materialResource: timber.id,
|
||||
durationMinutes: 60,
|
||||
crewRequired: 6,
|
||||
laborRequirements: [{ classification: crew.id, requiredHeadcount: 2, maxHeadcount: 6 }],
|
||||
});
|
||||
createdIds.structureBlueprints.push(blueprint.id);
|
||||
const site = await makeSite({
|
||||
name: `${RUN} Depot site`,
|
||||
blueprintId: blueprint.id,
|
||||
materialResource: timber.id,
|
||||
});
|
||||
createdIds.gameStructures.push(site.id);
|
||||
|
||||
await setLaborHeadcountService(payload, {
|
||||
structureId: site.id,
|
||||
classificationId: crew.id,
|
||||
headcount: 3,
|
||||
unitSalary: 1,
|
||||
});
|
||||
|
||||
const start = new Date();
|
||||
const started = await maybeStartConstruction(payload, site.id, start);
|
||||
expect(started.started).toBe(true);
|
||||
const during = await siteConstruction(site.id);
|
||||
expect(during.constructionStatus).toBe("building");
|
||||
// 3 of 6 required crew: 60 base minutes take 120.
|
||||
const spanMinutes =
|
||||
(new Date(during.completesAt as string).getTime() -
|
||||
new Date(during.startedAt as string).getTime()) /
|
||||
60_000;
|
||||
expect(spanMinutes).toBe(120);
|
||||
|
||||
// Full crew hired mid-build: the remaining work is re-priced at factor 1
|
||||
// and the deadline pulls in well before the original 120-minute mark.
|
||||
await setLaborHeadcountService(payload, {
|
||||
structureId: site.id,
|
||||
classificationId: crew.id,
|
||||
headcount: 6,
|
||||
unitSalary: 1,
|
||||
});
|
||||
const afterHire = await siteConstruction(site.id);
|
||||
const hiredSpan =
|
||||
(new Date(afterHire.completesAt as string).getTime() -
|
||||
new Date(afterHire.startedAt as string).getTime()) /
|
||||
60_000;
|
||||
expect(hiredSpan).toBeLessThan(120);
|
||||
expect(new Date(afterHire.completesAt as string).getTime()).toBeLessThan(
|
||||
new Date(during.completesAt as string).getTime(),
|
||||
);
|
||||
|
||||
// Releasing every worker floors the speed instead of freezing the build.
|
||||
await setLaborHeadcountService(payload, {
|
||||
structureId: site.id,
|
||||
classificationId: crew.id,
|
||||
headcount: 0,
|
||||
unitSalary: 1,
|
||||
});
|
||||
const afterRelease = await siteConstruction(site.id);
|
||||
expect(new Date(afterRelease.completesAt as string).getTime()).toBeGreaterThan(
|
||||
new Date(afterHire.completesAt as string).getTime(),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
350
tests/int/modules.int.spec.ts
Normal file
350
tests/int/modules.int.spec.ts
Normal file
|
|
@ -0,0 +1,350 @@
|
|||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { getPayload, type CollectionSlug, type Payload, type Where } from "payload";
|
||||
import type { Map as MapDoc, Resource, Structure } from "@/payload-types";
|
||||
|
||||
import config from "@payload-config";
|
||||
import dotenv from "dotenv";
|
||||
import { processProductionTick } from "@/lib/base/production";
|
||||
import { installModule, installedModuleEffects, uninstallModule } from "@/lib/base/modules";
|
||||
|
||||
dotenv.config({ path: ".env" });
|
||||
|
||||
const RUN = `mods-${Date.now()}`;
|
||||
const LEXICAL_EMPTY = {
|
||||
root: {
|
||||
children: [{ text: "" }],
|
||||
direction: null,
|
||||
format: "" as const,
|
||||
indent: 0,
|
||||
type: "text",
|
||||
version: 1,
|
||||
},
|
||||
};
|
||||
|
||||
let payload: Payload;
|
||||
|
||||
const createdIds = {
|
||||
maps: [] as number[],
|
||||
resources: [] as number[],
|
||||
structureBlueprints: [] as number[],
|
||||
gameStructures: [] as number[],
|
||||
classifications: [] as number[],
|
||||
};
|
||||
|
||||
async function makeResource(name: string, massPerUnit = 1): Promise<Resource> {
|
||||
return (await payload.create({
|
||||
collection: "resources",
|
||||
data: {
|
||||
name,
|
||||
codeName: `${RUN}-${name.toLowerCase().replace(/\s+/g, "-")}`,
|
||||
approvalStatus: "in_progress",
|
||||
type: "physical",
|
||||
baseValue: 1,
|
||||
rarity: "common",
|
||||
unitOfMeasure: "kg",
|
||||
massPerUnit,
|
||||
gridWidth: 1,
|
||||
gridHeight: 1,
|
||||
},
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
})) as unknown as Resource;
|
||||
}
|
||||
|
||||
async function makeBlueprint(data: {
|
||||
name: string;
|
||||
materialResource: number;
|
||||
moduleSlots: number;
|
||||
outputs?: { resource: number; amountPerHour: number }[];
|
||||
maxCapacityMass?: number;
|
||||
}): Promise<Structure> {
|
||||
return (await payload.create({
|
||||
collection: "structures",
|
||||
data: {
|
||||
name: data.name,
|
||||
codeName: data.name.toLowerCase().replace(/\s+/g, "_"),
|
||||
approvalStatus: "in_progress",
|
||||
description: LEXICAL_EMPTY as unknown as Structure["description"],
|
||||
category: "logistics",
|
||||
materials: [{ resource: data.materialResource, amount: 1 }],
|
||||
constructionDurationMinutes: 1,
|
||||
terrainType: "land",
|
||||
maxHealth: 100,
|
||||
maxCapacityMass: data.maxCapacityMass,
|
||||
productionOutputs: data.outputs,
|
||||
moduleSlots: data.moduleSlots,
|
||||
moduleOptions: [
|
||||
{
|
||||
name: `${RUN} Reinforced Flooring`,
|
||||
category: "logistics",
|
||||
effects: [{ type: "capacity_mass", value: 500 }],
|
||||
materials: [{ resource: data.materialResource, amount: 5 }],
|
||||
},
|
||||
{
|
||||
name: `${RUN} Overclocked Line`,
|
||||
category: "production",
|
||||
effects: [{ type: "production_multiplier", value: 0.5 }],
|
||||
materials: [{ resource: data.materialResource, amount: 2 }],
|
||||
},
|
||||
{
|
||||
name: `${RUN} Extra Racking`,
|
||||
category: "logistics",
|
||||
effects: [{ type: "storage_slots", value: 4 }],
|
||||
materials: [{ resource: data.materialResource, amount: 1 }],
|
||||
},
|
||||
],
|
||||
},
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
})) as unknown as Structure;
|
||||
}
|
||||
|
||||
async function makeSite(data: {
|
||||
name: string;
|
||||
blueprintId: number;
|
||||
materialResource: number;
|
||||
complete?: boolean;
|
||||
}): Promise<{ id: number }> {
|
||||
return (await payload.create({
|
||||
collection: "game-structures",
|
||||
data: {
|
||||
name: data.name,
|
||||
type: data.blueprintId,
|
||||
map: createdIds.maps[0],
|
||||
coordinates: [500, 500],
|
||||
constructionStatus: data.complete === false ? "building" : "complete",
|
||||
storedResources: [{ resource: data.materialResource, amount: 100, gridX: 0, gridY: 0 }],
|
||||
},
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
})) as unknown as { id: number };
|
||||
}
|
||||
|
||||
async function assignLabor(siteId: number): Promise<void> {
|
||||
const classification = (await payload.create({
|
||||
collection: "labor-classifications",
|
||||
data: {
|
||||
name: `${RUN} Crew ${siteId}`,
|
||||
key: `${RUN}-crew-${siteId}`,
|
||||
defaultUnitSalary: 1,
|
||||
availableHeadcount: 50,
|
||||
},
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
})) as unknown as { id: number };
|
||||
createdIds.classifications.push(classification.id);
|
||||
await payload.create({
|
||||
collection: "npc-staffing",
|
||||
data: {
|
||||
structure: siteId,
|
||||
positionType: "other",
|
||||
headcount: 5,
|
||||
laborClassification: classification.id,
|
||||
positionTitle: "Labor crew",
|
||||
status: "active",
|
||||
},
|
||||
overrideAccess: true,
|
||||
});
|
||||
}
|
||||
|
||||
async function storedAmountOf(
|
||||
siteId: number,
|
||||
resourceId: number,
|
||||
): Promise<{ stored: number; void: number; fuel: number }> {
|
||||
const doc = (await payload.findByID({
|
||||
collection: "game-structures",
|
||||
id: siteId,
|
||||
depth: 1,
|
||||
overrideAccess: true,
|
||||
})) as unknown as {
|
||||
storedResources: { resource: number | Resource; amount: number }[];
|
||||
voidStorage: { resource: number | Resource; amount: number }[];
|
||||
};
|
||||
const sum = (entries: { resource: number | Resource; amount: number }[]): number =>
|
||||
entries.reduce(
|
||||
(total, entry) =>
|
||||
total +
|
||||
((typeof entry.resource === "object" ? entry.resource.id : entry.resource) === resourceId
|
||||
? entry.amount
|
||||
: 0),
|
||||
0,
|
||||
);
|
||||
const fuelEntry = doc.voidStorage.find(
|
||||
(entry) =>
|
||||
(typeof entry.resource === "object" ? entry.resource.id : entry.resource) === resourceId,
|
||||
);
|
||||
return {
|
||||
stored: sum(doc.storedResources),
|
||||
void: sum(doc.voidStorage),
|
||||
fuel: fuelEntry?.amount ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
describe("Module system", () => {
|
||||
describe("pure effects", () => {
|
||||
it("aggregates installed module effects against the blueprint catalog", () => {
|
||||
const blueprint = {
|
||||
moduleSlots: 2,
|
||||
moduleOptions: [
|
||||
{ name: "A", effects: [{ type: "capacity_mass", value: 500 }] },
|
||||
{ name: "B", effects: [{ type: "production_multiplier", value: 0.25 }] },
|
||||
{ name: "C", effects: [{ type: "storage_slots", value: 2.7 }] },
|
||||
],
|
||||
};
|
||||
expect(installedModuleEffects(blueprint, ["A", "B", "C"])).toEqual({
|
||||
capacityMass: 500,
|
||||
productionMultiplier: 0.25,
|
||||
storageSlots: 2,
|
||||
});
|
||||
// Unknown and empty installs contribute nothing.
|
||||
expect(installedModuleEffects(blueprint, ["Z"])).toEqual({
|
||||
capacityMass: 0,
|
||||
productionMultiplier: 0,
|
||||
storageSlots: 0,
|
||||
});
|
||||
expect(installedModuleEffects({}, [])).toEqual({
|
||||
capacityMass: 0,
|
||||
productionMultiplier: 0,
|
||||
storageSlots: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("install / uninstall", () => {
|
||||
beforeAll(async () => {
|
||||
payload = await getPayload({ config });
|
||||
createdIds.maps.push(
|
||||
(
|
||||
(await payload.create({
|
||||
collection: "maps",
|
||||
data: {
|
||||
name: `${RUN} map`,
|
||||
worldSizeWidth: 8192,
|
||||
worldSizeHeight: 8192,
|
||||
basemapMode: "image",
|
||||
},
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
})) as unknown as MapDoc
|
||||
).id,
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
const sweep = async (collection: CollectionSlug, where: Where) => {
|
||||
const found = await payload
|
||||
.find({ collection, where, limit: 500, depth: 0, overrideAccess: true })
|
||||
.catch(() => null);
|
||||
for (const doc of found?.docs ?? []) {
|
||||
await payload.delete({ collection, id: doc.id, overrideAccess: true }).catch(() => {});
|
||||
}
|
||||
};
|
||||
await sweep("npc-staffing", { structure: { in: createdIds.gameStructures } });
|
||||
await sweep("game-event-logs", { structure: { in: createdIds.gameStructures } });
|
||||
await sweep("game-structures", { name: { like: `${RUN}%` } });
|
||||
await sweep("structures", { name: { like: `${RUN}%` } });
|
||||
await sweep("labor-classifications", { name: { like: `${RUN}%` } });
|
||||
await sweep("resources", { name: { like: `${RUN}%` } });
|
||||
await sweep("maps", { name: { like: `${RUN}%` } });
|
||||
});
|
||||
|
||||
it("consumes materials, records the module, and enforces slots", async () => {
|
||||
const timber = await makeResource("Timber");
|
||||
createdIds.resources.push(timber.id);
|
||||
const blueprint = await makeBlueprint({
|
||||
name: `${RUN} Warehouse`,
|
||||
materialResource: timber.id,
|
||||
moduleSlots: 2,
|
||||
});
|
||||
createdIds.structureBlueprints.push(blueprint.id);
|
||||
const site = await makeSite({
|
||||
name: `${RUN} Warehouse site`,
|
||||
blueprintId: blueprint.id,
|
||||
materialResource: timber.id,
|
||||
});
|
||||
createdIds.gameStructures.push(site.id);
|
||||
|
||||
await installModule(payload, site.id, `${RUN} Reinforced Flooring`);
|
||||
const afterFirst = await storedAmountOf(site.id, timber.id);
|
||||
expect(afterFirst.stored).toBe(95);
|
||||
|
||||
await expect(installModule(payload, site.id, `${RUN} Reinforced Flooring`)).rejects.toThrow(
|
||||
"already installed",
|
||||
);
|
||||
await expect(installModule(payload, site.id, "No Such Module")).rejects.toThrow(
|
||||
"Unknown module",
|
||||
);
|
||||
|
||||
await installModule(payload, site.id, `${RUN} Overclocked Line`);
|
||||
await expect(installModule(payload, site.id, `${RUN} Extra Racking`)).rejects.toThrow(
|
||||
"All 2 module slot(s)",
|
||||
);
|
||||
|
||||
const uninstalled = await uninstallModule(payload, site.id, `${RUN} Overclocked Line`);
|
||||
expect(uninstalled.map((entry) => entry.name)).toEqual([`${RUN} Reinforced Flooring`]);
|
||||
await expect(uninstallModule(payload, site.id, `${RUN} Overclocked Line`)).rejects.toThrow(
|
||||
"not installed",
|
||||
);
|
||||
await installModule(payload, site.id, `${RUN} Extra Racking`);
|
||||
});
|
||||
|
||||
it("refuses installation outside operational structures", async () => {
|
||||
const timber = await makeResource("Planks");
|
||||
createdIds.resources.push(timber.id);
|
||||
const blueprint = await makeBlueprint({
|
||||
name: `${RUN} Yard`,
|
||||
materialResource: timber.id,
|
||||
moduleSlots: 1,
|
||||
});
|
||||
createdIds.structureBlueprints.push(blueprint.id);
|
||||
const site = await makeSite({
|
||||
name: `${RUN} Yard site`,
|
||||
blueprintId: blueprint.id,
|
||||
materialResource: timber.id,
|
||||
complete: false,
|
||||
});
|
||||
createdIds.gameStructures.push(site.id);
|
||||
|
||||
await expect(installModule(payload, site.id, `${RUN} Reinforced Flooring`)).rejects.toThrow(
|
||||
"operational",
|
||||
);
|
||||
});
|
||||
|
||||
it("applies production multiplier and capacity bonus in the tick", async () => {
|
||||
const timber = await makeResource("Beams");
|
||||
const fuel = await makeResource("Fuel");
|
||||
createdIds.resources.push(timber.id, fuel.id);
|
||||
const blueprint = await makeBlueprint({
|
||||
name: `${RUN} Refinery`,
|
||||
materialResource: timber.id,
|
||||
moduleSlots: 2,
|
||||
outputs: [{ resource: fuel.id, amountPerHour: 1000 }],
|
||||
maxCapacityMass: 100,
|
||||
});
|
||||
createdIds.structureBlueprints.push(blueprint.id);
|
||||
const site = await makeSite({
|
||||
name: `${RUN} Refinery site`,
|
||||
blueprintId: blueprint.id,
|
||||
materialResource: timber.id,
|
||||
});
|
||||
createdIds.gameStructures.push(site.id);
|
||||
await assignLabor(site.id);
|
||||
|
||||
// Baseline without modules: 1000 kg clamped to the 100 kg cap.
|
||||
const start = new Date();
|
||||
await processProductionTick(payload, undefined, start, [site.id]);
|
||||
await processProductionTick(payload, undefined, new Date(start.getTime() + 3_600_000), [
|
||||
site.id,
|
||||
]);
|
||||
expect((await storedAmountOf(site.id, fuel.id)).fuel).toBe(100);
|
||||
|
||||
// Install capacity (+500) and multiplier (+50%): output clamps at 600.
|
||||
await installModule(payload, site.id, `${RUN} Reinforced Flooring`);
|
||||
await installModule(payload, site.id, `${RUN} Overclocked Line`);
|
||||
await processProductionTick(payload, undefined, new Date(start.getTime() + 7_200_000), [
|
||||
site.id,
|
||||
]);
|
||||
expect((await storedAmountOf(site.id, fuel.id)).fuel).toBe(600);
|
||||
});
|
||||
});
|
||||
});
|
||||
327
tests/int/production.int.spec.ts
Normal file
327
tests/int/production.int.spec.ts
Normal file
|
|
@ -0,0 +1,327 @@
|
|||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { getPayload, type CollectionSlug, type Payload, type Where } from "payload";
|
||||
import type { LaborClassification, Map as MapDoc, Resource, Structure } from "@/payload-types";
|
||||
|
||||
import config from "@payload-config";
|
||||
import dotenv from "dotenv";
|
||||
import { processProductionTick } from "@/lib/base/production";
|
||||
|
||||
dotenv.config({ path: ".env" });
|
||||
|
||||
const RUN = `prod-${Date.now()}`;
|
||||
const LEXICAL_EMPTY = {
|
||||
root: {
|
||||
children: [{ text: "" }],
|
||||
direction: null,
|
||||
format: "" as const,
|
||||
indent: 0,
|
||||
type: "text",
|
||||
version: 1,
|
||||
},
|
||||
};
|
||||
|
||||
let payload: Payload;
|
||||
let laborCounter = 0;
|
||||
|
||||
const createdIds = {
|
||||
maps: [] as number[],
|
||||
resources: [] as number[],
|
||||
structureBlueprints: [] as number[],
|
||||
gameStructures: [] as number[],
|
||||
classifications: [] as number[],
|
||||
};
|
||||
|
||||
async function makeResource(name: string, massPerUnit = 1): Promise<Resource> {
|
||||
return (await payload.create({
|
||||
collection: "resources",
|
||||
data: {
|
||||
name,
|
||||
codeName: `${RUN}-${name.toLowerCase().replace(/\s+/g, "-")}`,
|
||||
approvalStatus: "in_progress",
|
||||
type: "physical",
|
||||
baseValue: 1,
|
||||
rarity: "common",
|
||||
unitOfMeasure: "kg",
|
||||
massPerUnit,
|
||||
gridWidth: 1,
|
||||
gridHeight: 1,
|
||||
},
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
})) as unknown as Resource;
|
||||
}
|
||||
|
||||
async function makeBlueprint(data: {
|
||||
name: string;
|
||||
inputs: { resource: number; amountPerHour: number }[];
|
||||
outputs: { resource: number; amountPerHour: number }[];
|
||||
maxCapacityMass?: number;
|
||||
}): Promise<Structure> {
|
||||
return (await payload.create({
|
||||
collection: "structures",
|
||||
data: {
|
||||
name: data.name,
|
||||
codeName: data.name.toLowerCase().replace(/\s+/g, "_"),
|
||||
approvalStatus: "in_progress",
|
||||
description: LEXICAL_EMPTY as unknown as Structure["description"],
|
||||
category: "logistics",
|
||||
materials: [{ resource: (data.outputs[0] ?? data.inputs[0]).resource, amount: 1 }],
|
||||
constructionDurationMinutes: 1,
|
||||
terrainType: "land",
|
||||
maxHealth: 100,
|
||||
maxCapacityMass: data.maxCapacityMass,
|
||||
productionInputs: data.inputs,
|
||||
productionOutputs: data.outputs,
|
||||
},
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
})) as unknown as Structure;
|
||||
}
|
||||
|
||||
async function makeSite(data: {
|
||||
name: string;
|
||||
blueprintId: number;
|
||||
paused?: boolean;
|
||||
}): Promise<{ id: number; name: string }> {
|
||||
return (await payload.create({
|
||||
collection: "game-structures",
|
||||
data: {
|
||||
name: data.name,
|
||||
type: data.blueprintId,
|
||||
map: createdIds.maps[0],
|
||||
coordinates: [500, 500],
|
||||
constructionStatus: "complete",
|
||||
productionPaused: data.paused ?? false,
|
||||
},
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
})) as unknown as { id: number; name: string };
|
||||
}
|
||||
|
||||
async function assignLabor(siteId: number, headcount = 5): Promise<void> {
|
||||
laborCounter += 1;
|
||||
const classification = (await payload.create({
|
||||
collection: "labor-classifications",
|
||||
data: {
|
||||
name: `${RUN} Workers ${laborCounter}`,
|
||||
key: `${RUN}-workers-${laborCounter}`,
|
||||
defaultUnitSalary: 1,
|
||||
availableHeadcount: 50,
|
||||
},
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
})) as unknown as LaborClassification;
|
||||
createdIds.classifications.push(classification.id);
|
||||
await payload.create({
|
||||
collection: "npc-staffing",
|
||||
data: {
|
||||
structure: siteId,
|
||||
positionType: "other",
|
||||
headcount,
|
||||
laborClassification: classification.id,
|
||||
positionTitle: "Labor crew",
|
||||
status: "active",
|
||||
},
|
||||
overrideAccess: true,
|
||||
});
|
||||
}
|
||||
|
||||
async function stallReason(siteId: number): Promise<string | null> {
|
||||
const doc = (await payload.findByID({
|
||||
collection: "game-structures",
|
||||
id: siteId,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})) as unknown as { productionStallReason: string | null };
|
||||
return doc.productionStallReason;
|
||||
}
|
||||
|
||||
describe("Production system", () => {
|
||||
beforeAll(async () => {
|
||||
payload = await getPayload({ config });
|
||||
createdIds.maps.push(
|
||||
(
|
||||
(await payload.create({
|
||||
collection: "maps",
|
||||
data: {
|
||||
name: `${RUN} map`,
|
||||
worldSizeWidth: 8192,
|
||||
worldSizeHeight: 8192,
|
||||
basemapMode: "image",
|
||||
},
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
})) as unknown as MapDoc
|
||||
).id,
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
const sweep = async (collection: CollectionSlug, where: Where) => {
|
||||
const found = await payload
|
||||
.find({ collection, where, limit: 500, depth: 0, overrideAccess: true })
|
||||
.catch(() => null);
|
||||
for (const doc of found?.docs ?? []) {
|
||||
await payload.delete({ collection, id: doc.id, overrideAccess: true }).catch(() => {});
|
||||
}
|
||||
};
|
||||
await sweep("npc-staffing", { structure: { in: createdIds.gameStructures } });
|
||||
await sweep("game-event-logs", { structure: { in: createdIds.gameStructures } });
|
||||
await sweep("game-structures", { name: { like: `${RUN}%` } });
|
||||
await sweep("structures", { name: { like: `${RUN}%` } });
|
||||
await sweep("labor-classifications", { name: { like: `${RUN}%` } });
|
||||
await sweep("resources", { name: { like: `${RUN}%` } });
|
||||
await sweep("maps", { name: { like: `${RUN}%` } });
|
||||
});
|
||||
|
||||
it("consumes inputs, produces outputs, and advances the clock", async () => {
|
||||
const ore = await makeResource("Ore");
|
||||
const fuel = await makeResource("Fuel");
|
||||
createdIds.resources.push(ore.id, fuel.id);
|
||||
const blueprint = await makeBlueprint({
|
||||
name: `${RUN} Refinery`,
|
||||
inputs: [{ resource: ore.id, amountPerHour: 100 }],
|
||||
outputs: [{ resource: fuel.id, amountPerHour: 50 }],
|
||||
});
|
||||
createdIds.structureBlueprints.push(blueprint.id);
|
||||
const site = await makeSite({ name: `${RUN} Refinery site`, blueprintId: blueprint.id });
|
||||
createdIds.gameStructures.push(site.id);
|
||||
|
||||
// Prime the clock, staff the site, then run an hour later with ore in storage.
|
||||
const start = new Date();
|
||||
await processProductionTick(payload, undefined, start, [site.id]);
|
||||
await assignLabor(site.id);
|
||||
await payload.update({
|
||||
collection: "game-structures",
|
||||
id: site.id,
|
||||
data: { storedResources: [{ resource: ore.id, amount: 500, gridX: 0, gridY: 0 }] },
|
||||
overrideAccess: true,
|
||||
});
|
||||
const result = await processProductionTick(
|
||||
payload,
|
||||
undefined,
|
||||
new Date(start.getTime() + 3_600_000),
|
||||
[site.id],
|
||||
);
|
||||
expect(result.produced).toBe(1);
|
||||
|
||||
const after = (await payload.findByID({
|
||||
collection: "game-structures",
|
||||
id: site.id,
|
||||
depth: 1,
|
||||
overrideAccess: true,
|
||||
})) as unknown as {
|
||||
voidStorage: { resource: number | Resource; amount: number }[];
|
||||
storedResources: { resource: number | Resource; amount: number }[];
|
||||
};
|
||||
|
||||
const fuelStored = after.voidStorage.find(
|
||||
(entry) => (entry.resource as Resource).id === fuel.id,
|
||||
);
|
||||
expect(fuelStored?.amount).toBe(50);
|
||||
const oreLeft = after.storedResources.find(
|
||||
(entry) => (entry.resource as Resource).id === ore.id,
|
||||
);
|
||||
expect(oreLeft?.amount).toBe(400);
|
||||
});
|
||||
|
||||
it("stalls on missing labor and missing inputs with surfaced reasons", async () => {
|
||||
const scrap = await makeResource("Scrap");
|
||||
createdIds.resources.push(scrap.id);
|
||||
const blueprint = await makeBlueprint({
|
||||
name: `${RUN} Mill`,
|
||||
inputs: [{ resource: scrap.id, amountPerHour: 10 }],
|
||||
outputs: [{ resource: scrap.id, amountPerHour: 5 }],
|
||||
});
|
||||
createdIds.structureBlueprints.push(blueprint.id);
|
||||
const site = await makeSite({ name: `${RUN} Mill site`, blueprintId: blueprint.id });
|
||||
createdIds.gameStructures.push(site.id);
|
||||
|
||||
const start = new Date();
|
||||
await processProductionTick(payload, undefined, start, [site.id]);
|
||||
|
||||
// No labor crew assigned: no_labor.
|
||||
await processProductionTick(payload, undefined, new Date(start.getTime() + 3_600_000), [
|
||||
site.id,
|
||||
]);
|
||||
expect(await stallReason(site.id)).toBe("no_labor");
|
||||
|
||||
// With labor but no ore: missing_inputs.
|
||||
const classification = (await payload.create({
|
||||
collection: "labor-classifications",
|
||||
data: {
|
||||
name: `${RUN} Workers`,
|
||||
key: `${RUN}-workers`,
|
||||
defaultUnitSalary: 1,
|
||||
availableHeadcount: 50,
|
||||
},
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
})) as unknown as LaborClassification;
|
||||
createdIds.classifications.push(classification.id);
|
||||
await payload.create({
|
||||
collection: "npc-staffing",
|
||||
data: {
|
||||
structure: site.id,
|
||||
positionType: "other",
|
||||
headcount: 5,
|
||||
laborClassification: classification.id,
|
||||
positionTitle: "Labor crew",
|
||||
status: "active",
|
||||
},
|
||||
overrideAccess: true,
|
||||
});
|
||||
await assignLabor(site.id);
|
||||
await processProductionTick(payload, undefined, new Date(start.getTime() + 7_200_000), [
|
||||
site.id,
|
||||
]);
|
||||
expect(await stallReason(site.id)).toBe("missing_inputs");
|
||||
});
|
||||
|
||||
it("respects the pause flag and clamps output to storage capacity", async () => {
|
||||
const grain = await makeResource("Grain");
|
||||
createdIds.resources.push(grain.id);
|
||||
const blueprint = await makeBlueprint({
|
||||
name: `${RUN} Silo`,
|
||||
inputs: [],
|
||||
outputs: [{ resource: grain.id, amountPerHour: 1000 }],
|
||||
maxCapacityMass: 300,
|
||||
});
|
||||
createdIds.structureBlueprints.push(blueprint.id);
|
||||
|
||||
const pausedSite = await makeSite({
|
||||
name: `${RUN} Silo paused`,
|
||||
blueprintId: blueprint.id,
|
||||
paused: true,
|
||||
});
|
||||
createdIds.gameStructures.push(pausedSite.id);
|
||||
const start = new Date();
|
||||
await processProductionTick(payload, undefined, start);
|
||||
await processProductionTick(payload, undefined, new Date(start.getTime() + 3_600_000), [
|
||||
pausedSite.id,
|
||||
]);
|
||||
const after = (await payload.findByID({
|
||||
collection: "game-structures",
|
||||
id: pausedSite.id,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})) as unknown as { lastProductionAt: string | null; voidStorage: unknown[] };
|
||||
expect(after.lastProductionAt).toBeNull();
|
||||
expect(after.voidStorage).toHaveLength(0);
|
||||
|
||||
const capped = await makeSite({ name: `${RUN} Silo capped`, blueprintId: blueprint.id });
|
||||
createdIds.gameStructures.push(capped.id);
|
||||
await assignLabor(capped.id);
|
||||
await processProductionTick(payload, undefined, start, [capped.id]);
|
||||
await processProductionTick(payload, undefined, new Date(start.getTime() + 3_600_000), [
|
||||
capped.id,
|
||||
]);
|
||||
const cappedAfter = (await payload.findByID({
|
||||
collection: "game-structures",
|
||||
id: capped.id,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})) as unknown as { voidStorage: { amount: number }[] };
|
||||
expect(cappedAfter.voidStorage[0]?.amount).toBe(300);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue