1
0
Fork 0

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:
Jason Fraley 2026-09-20 02:43:23 -04:00
parent 07eb8aa22e
commit 7a0f997f70
40 changed files with 147944 additions and 35 deletions

View file

@ -9,6 +9,12 @@ import {
setLaborHeadcount as setLaborHeadcountAssignment, setLaborHeadcount as setLaborHeadcountAssignment,
upgradeStructure as upgradeStructureService, upgradeStructure as upgradeStructureService,
} from "@/lib/base"; } 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 { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQualification";
import { hasPermission } from "@/utils/access-control/hasPermission"; import { hasPermission } from "@/utils/access-control/hasPermission";
import { isPayloadUser } from "@/utils/access-control/isPayloadUser"; 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( export async function setLaborHeadcount(
structureId: number, structureId: number,
classificationId: number, classificationId: number,

View file

@ -46,6 +46,7 @@ export async function placeStructure(params: {
blueprintId: number; blueprintId: number;
name: string; name: string;
factionId?: number | null; factionId?: number | null;
parentStructureId?: number | null;
x: number; x: number;
y: number; y: number;
}): Promise<ActionResult> { }): 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([ const [zonesRes, nodesRes, structuresRes] = await Promise.all([
payload.find({ payload.find({
collection: "map-zones", collection: "map-zones",
@ -184,6 +206,7 @@ export async function placeStructure(params: {
map: mapId, map: mapId,
coordinates: point, coordinates: point,
constructionStatus: "awaiting_materials", constructionStatus: "awaiting_materials",
parentStructure: parentStructureId,
}, },
}); });
@ -240,7 +263,7 @@ export interface MapZoneInput {
mapId: number; mapId: number;
name?: string | null; name?: string | null;
description?: string | null; description?: string | null;
type: "water" | "land" | "territory" | "border"; type: "water" | "land" | "territory" | "border" | "other";
resourceIds?: number[]; resourceIds?: number[];
fillColor?: string | null; fillColor?: string | null;
strokeColor?: string | null; strokeColor?: string | null;

View file

@ -1,12 +1,10 @@
import { getPayload, type Payload } from "payload"; import { getPayload, type Payload } from "payload";
import type { Faction, GameStructure, Structure } from "@/payload-types"; import type { Faction, GameStructure, Structure } from "@/payload-types";
import { convertLexicalToPlaintext } from "@payloadcms/richtext-lexical/plaintext"; import { convertLexicalToPlaintext } from "@payloadcms/richtext-lexical/plaintext";
import { import { calculateStoredMass, calculateUtilization, getGridSlots } from "@/lib/logistics";
calculateStoredMass,
calculateUtilization,
getGridSlots,
} from "@/lib/logistics";
import type { LaborClassificationOption, StructureStaffingOption } from "@/lib/base"; 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 { currencyConfigFromGameRules } from "@/lib/banking";
import { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQualification"; import { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQualification";
import { isSuperuser } from "@/utils/access-control/hasPermission"; import { isSuperuser } from "@/utils/access-control/hasPermission";
@ -22,11 +20,29 @@ export interface StructureDetailData {
gridSlots: number | null; gridSlots: number | null;
totalValue: number; totalValue: number;
constructionMaterials: { name: string; required: number; delivered: number }[]; constructionMaterials: { name: string; required: number; delivered: number }[];
constructionCrew: { required: number; effective: number };
}; };
upgrade: { upgrade: {
targetName: string; targetName: string;
materials: { name: string; needed: number; have: number }[]; materials: { name: string; needed: number; have: number }[];
} | null; } | 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: { staffing: {
structureStaffing: StructureStaffingOption; structureStaffing: StructureStaffingOption;
staff: { staff: {
@ -76,8 +92,16 @@ export async function fetchStructureDetail(
const structureType = structure.type; const structureType = structure.type;
const storedResources = structure.storedResources ?? []; const storedResources = structure.storedResources ?? [];
const currentMass = calculateStoredMass(storedResources); const currentMass = calculateStoredMass(storedResources);
const utilization = calculateUtilization(structureType, storedResources); const moduleEffects = installedModuleEffects(
const gridSlots = getGridSlots(structureType); structureType,
(structure.installedModules ?? []).map((entry) => entry.name),
);
const utilization = calculateUtilization(
structureType,
storedResources,
moduleEffects.capacityMass,
);
const gridSlots = getGridSlots(structureType, moduleEffects.storageSlots);
const description = structureType.description const description = structureType.description
? convertLexicalToPlaintext({ data: structureType.description }) ? convertLexicalToPlaintext({ data: structureType.description })
: null; : null;
@ -194,6 +218,9 @@ export async function fetchStructureDetail(
limit: 100, limit: 100,
depth: 0, depth: 0,
}); });
const buildEfficiencyById = new Map(
classificationsRes.docs.map((doc) => [doc.id, doc.buildEfficiency ?? 1]),
);
const classifications: LaborClassificationOption[] = classificationsRes.docs.map((doc) => ({ const classifications: LaborClassificationOption[] = classificationsRes.docs.map((doc) => ({
id: doc.id, id: doc.id,
name: doc.name, 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 { return {
structure, structure,
blueprint: structureType, blueprint: structureType,
description, description,
imageUrl, imageUrl,
derived: { currentMass, utilization, gridSlots, totalValue, constructionMaterials }, derived: {
currentMass,
utilization,
gridSlots,
totalValue,
constructionMaterials,
constructionCrew,
},
upgrade, upgrade,
production,
modules,
staffing: { staffing: {
structureStaffing, structureStaffing,
staff, staff,

View file

@ -10,6 +10,8 @@ import { StorageRules } from "@/components/frontend/storage/StorageRules";
import { StructureStorageView } from "@/components/frontend/storage/StructureStorageView"; import { StructureStorageView } from "@/components/frontend/storage/StructureStorageView";
import { StaffRoster } from "@/components/frontend/baseManagement/StaffRoster"; import { StaffRoster } from "@/components/frontend/baseManagement/StaffRoster";
import { UpgradeCard } from "@/components/frontend/baseManagement/UpgradeCard"; 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 { ConstructionPanel } from "@/components/frontend/baseManagement/ConstructionPanel";
import { import {
calculateStoredMass, calculateStoredMass,
@ -20,6 +22,8 @@ import {
} from "@/lib/logistics"; } from "@/lib/logistics";
import { pluralize } from "@/lib/utils"; import { pluralize } from "@/lib/utils";
import type { LaborClassificationOption, StructureStaffingOption } from "@/lib/base"; 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 { currencyConfigFromGameRules } from "@/lib/banking";
import { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQualification"; import { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQualification";
import { isSuperuser } from "@/utils/access-control/hasPermission"; import { isSuperuser } from "@/utils/access-control/hasPermission";
@ -71,9 +75,33 @@ export default async function StructureDetailPage({ params }: StructureDetailPag
const structureType = structure.type; const structureType = structure.type;
const storedResources = structure.storedResources ?? []; 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 currentMass = calculateStoredMass(storedResources);
const utilization = calculateUtilization(structureType, storedResources); const moduleEffects = installedModuleEffects(
const gridSlots = getGridSlots(structureType); 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 functionLabel = getFunctionTypeLabel(structureType.functionType);
const description = structureType.description const description = structureType.description
? convertLexicalToPlaintext({ data: structureType.description }) ? convertLexicalToPlaintext({ data: structureType.description })
@ -193,6 +221,9 @@ export default async function StructureDetailPage({ params }: StructureDetailPag
limit: 100, limit: 100,
depth: 0, depth: 0,
}); });
const buildEfficiencyById = new Map(
classificationsRes.docs.map((doc) => [doc.id, doc.buildEfficiency ?? 1]),
);
const classifications: LaborClassificationOption[] = classificationsRes.docs.map((doc) => ({ const classifications: LaborClassificationOption[] = classificationsRes.docs.map((doc) => ({
id: doc.id, id: doc.id,
name: doc.name, name: doc.name,
@ -200,6 +231,20 @@ export default async function StructureDetailPage({ params }: StructureDetailPag
defaultUnitSalary: doc.defaultUnitSalary ?? null, defaultUnitSalary: doc.defaultUnitSalary ?? null,
availableHeadcount: doc.availableHeadcount ?? 0, 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({ const rules = await payload.findGlobal({
slug: "game-rules", slug: "game-rules",
@ -434,6 +479,45 @@ export default async function StructureDetailPage({ params }: StructureDetailPag
)} )}
</div> </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} /> <UpgradeCard gameStructureId={structure.id} upgrade={upgrade} />
<ConstructionPanel <ConstructionPanel
@ -442,6 +526,55 @@ export default async function StructureDetailPage({ params }: StructureDetailPag
completesAt={structure.constructionCompletesAt ?? null} completesAt={structure.constructionCompletesAt ?? null}
durationMinutes={structureType.constructionDurationMinutes ?? null} durationMinutes={structureType.constructionDurationMinutes ?? null}
materials={constructionMaterials} 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 <StaffRoster

View file

@ -7,6 +7,7 @@ import { isSuperuser } from "@/utils/access-control/hasPermission";
import { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQualification"; import { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQualification";
import { emitGameEvent } from "@/utils/event-log/emit"; import { emitGameEvent } from "@/utils/event-log/emit";
import { EventTypes } from "@/utils/event-log/eventTypes"; import { EventTypes } from "@/utils/event-log/eventTypes";
import { effectiveMaxMass } from "@/lib/base/modules";
import { checkStorageDeposit, storageViolationMessage } from "@/lib/storageRules"; import { checkStorageDeposit, storageViolationMessage } from "@/lib/storageRules";
import { maybeStartConstruction } from "@/lib/map/construction"; import { maybeStartConstruction } from "@/lib/map/construction";
@ -105,7 +106,7 @@ export async function addResource(
} }
} }
async function addResourceInternal( export async function addResourceInternal(
payload: PayloadClient, payload: PayloadClient,
user: AuthedUser, user: AuthedUser,
structureId: number, structureId: number,
@ -150,7 +151,7 @@ async function addResourceInternal(
// Check capacity // Check capacity
const currentMass = calculateMass(structure.storedResources); const currentMass = calculateMass(structure.storedResources);
const addedMass = (resource.massPerUnit ?? 0) * amount; const addedMass = (resource.massPerUnit ?? 0) * amount;
const maxMass = structureType.maxCapacityMass; const maxMass = effectiveMaxMass(structureType, structure.installedModules);
if (maxMass && currentMass + addedMass > maxMass) { if (maxMass && currentMass + addedMass > maxMass) {
return { return {
@ -511,7 +512,7 @@ export async function transferResource(
// Check destination capacity // Check destination capacity
const destMass = calculateMass(toStructure.storedResources); const destMass = calculateMass(toStructure.storedResources);
const addedMass = (resource.massPerUnit ?? 0) * amount; const addedMass = (resource.massPerUnit ?? 0) * amount;
const destMaxMass = toStructure.type.maxCapacityMass; const destMaxMass = effectiveMaxMass(toStructure.type, toStructure.installedModules);
if (destMaxMass && destMass + addedMass > destMaxMass) { if (destMaxMass && destMass + addedMass > destMaxMass) {
return { return {
@ -620,7 +621,7 @@ export async function placeResourceOnGrid(
const currentMass = calculateMass(structure.storedResources); const currentMass = calculateMass(structure.storedResources);
const addedMass = (resource.massPerUnit ?? 0) * amount; const addedMass = (resource.massPerUnit ?? 0) * amount;
const maxMass = structureType.maxCapacityMass; const maxMass = effectiveMaxMass(structureType, structure.installedModules);
if (maxMass && currentMass + addedMass > maxMass) { if (maxMass && currentMass + addedMass > maxMass) {
return { return {
success: false, success: false,

View file

@ -2,6 +2,12 @@ import { NextRequest, NextResponse } from "next/server";
import config from "@payload-config"; import config from "@payload-config";
import { getPayload } from "payload"; import { getPayload } from "payload";
import type { GameStructure, Map as MapDoc, Shipment } from "@/payload-types"; 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 { isPointArray, type MapPoint } from "@/lib/map/points";
import type { MapFeaturesResponse, StructureFeature } from "@/lib/map/types"; import type { MapFeaturesResponse, StructureFeature } from "@/lib/map/types";
@ -132,9 +138,11 @@ export async function GET(req: NextRequest) {
constructionCompletesAt: structure.constructionCompletesAt ?? null, constructionCompletesAt: structure.constructionCompletesAt ?? null,
constructionDurationMinutes: blueprint?.constructionDurationMinutes ?? null, constructionDurationMinutes: blueprint?.constructionDurationMinutes ?? null,
storageUsedKg, storageUsedKg,
storageCapacityKg: blueprint?.maxCapacityMass ?? null, storageCapacityKg: blueprint ? effectiveMaxMass(blueprint, structure.installedModules) : null,
labelVisible: structure.labelVisible ?? null, labelVisible: structure.labelVisible ?? null,
labelPriority: structure.labelPriority ?? null, labelPriority: structure.labelPriority ?? null,
parentId: compoundParentId(structure),
compoundChildren: 0,
blueprintId: blueprint?.id ?? null, blueprintId: blueprint?.id ?? null,
blueprintName: blueprint?.name ?? null, blueprintName: blueprint?.name ?? null,
blueprintTerrain: (blueprint?.terrainType as "land" | "water" | undefined) ?? 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 }[] => const relEntries = (value: unknown): { id: number; name: string }[] =>
(Array.isArray(value) ? value : []).map((entry) => (Array.isArray(value) ? value : []).map((entry) =>
typeof entry === "object" && entry !== null && "id" in entry typeof entry === "object" && entry !== null && "id" in entry

View file

@ -3,6 +3,9 @@ import { requirePermission } from "@/utils/access-control/hasPermission";
import { emitGameEvent } from "@/utils/event-log/emit"; import { emitGameEvent } from "@/utils/event-log/emit";
import { staffingDefaultsFromBlueprint } from "@/lib/base/staffingDefaults"; import { staffingDefaultsFromBlueprint } from "@/lib/base/staffingDefaults";
import { createStaffingFields } from "./staffingFields"; import { createStaffingFields } from "./staffingFields";
import { createProductionFields } from "./productionFields";
import { createModuleFields } from "./moduleFields";
import { productionDefaultsFromBlueprint } from "@/lib/base/productionDefaults";
export const GameStructures: CollectionConfig = { export const GameStructures: CollectionConfig = {
slug: "game-structures", slug: "game-structures",
@ -28,6 +31,7 @@ export const GameStructures: CollectionConfig = {
overrideAccess: true, overrideAccess: true,
}); });
const defaults = staffingDefaultsFromBlueprint(blueprint); const defaults = staffingDefaultsFromBlueprint(blueprint);
const productionDefaults = productionDefaultsFromBlueprint(blueprint);
return { return {
...data, ...data,
@ -35,6 +39,12 @@ export const GameStructures: CollectionConfig = {
? { laborRequirements: defaults.laborRequirements } ? { laborRequirements: defaults.laborRequirements }
: {}), : {}),
...(data.staffPositions === undefined ? { staffPositions: defaults.staffPositions } : {}), ...(data.staffPositions === undefined ? { staffPositions: defaults.staffPositions } : {}),
...(data.productionInputs === undefined
? { productionInputs: productionDefaults.productionInputs }
: {}),
...(data.productionOutputs === undefined
? { productionOutputs: productionDefaults.productionOutputs }
: {}),
...(data.maintenanceCost === undefined ...(data.maintenanceCost === undefined
? { maintenanceCost: defaults.maintenanceCost } ? { maintenanceCost: defaults.maintenanceCost }
: {}), : {}),
@ -127,7 +137,8 @@ export const GameStructures: CollectionConfig = {
type: "date", type: "date",
label: "Construction Started At", label: "Construction Started At",
admin: { 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"), ...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", label: "Geolocation",
type: "group", 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.", "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", name: "storedResources",
type: "array", type: "array",

View file

@ -52,6 +52,17 @@ export const LaborClassifications: CollectionConfig = {
type: "textarea", type: "textarea",
label: "Description", 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", name: "availableHeadcount",
type: "number", type: "number",

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

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

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

View file

@ -3,6 +3,9 @@ import { isDeveloper } from "@/utils/access-control/isRole";
import type { Resource } from "@/payload-types"; import type { Resource } from "@/payload-types";
import { emitGameEvent } from "@/utils/event-log/emit"; import { emitGameEvent } from "@/utils/event-log/emit";
import { createStaffingFields } from "@/collections/game/staffingFields"; 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 = { export const Structures: CollectionConfig = {
slug: "structures", 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.", "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", name: "requiredTech",
type: "relationship", type: "relationship",
@ -498,6 +512,17 @@ export const Structures: CollectionConfig = {
type: "group", type: "group",
fields: createStaffingFields("defaults"), fields: createStaffingFields("defaults"),
}, },
{
label: "Production Recipe Defaults",
type: "group",
fields: createProductionFields("defaults"),
},
{
label: "Modules",
type: "group",
fields: createModuleFields("defaults"),
},
...createAuraFields(),
{ {
label: "Production & Function", label: "Production & Function",

View file

@ -15,6 +15,7 @@ interface ConstructionPanelProps {
completesAt: string | null; completesAt: string | null;
durationMinutes: number | null; durationMinutes: number | null;
materials: ConstructionMaterial[]; materials: ConstructionMaterial[];
crew?: { required: number; effective: number } | null;
} }
const STATUS_LABELS: Record<string, { label: string; className: string }> = { const STATUS_LABELS: Record<string, { label: string; className: string }> = {
@ -48,6 +49,7 @@ export function ConstructionPanel({
completesAt, completesAt,
durationMinutes, durationMinutes,
materials, materials,
crew,
}: ConstructionPanelProps) { }: ConstructionPanelProps) {
const [nowMs, setNowMs] = useState(() => Date.now()); const [nowMs, setNowMs] = useState(() => Date.now());
@ -62,6 +64,30 @@ export function ConstructionPanel({
const allDelivered = materials.every((material) => material.delivered >= material.required); const allDelivered = materials.every((material) => material.delivered >= material.required);
const pct = status === "building" ? progressPct(startedAt, completesAt, nowMs) : 0; 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 ( return (
<div className="rounded-lg border border-border p-5 flex flex-col gap-3"> <div className="rounded-lg border border-border p-5 flex flex-col gap-3">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
@ -82,10 +108,7 @@ export function ConstructionPanel({
<span>{pct}%</span> <span>{pct}%</span>
</div> </div>
<div className="h-1.5 w-full overflow-hidden rounded-full bg-muted"> <div className="h-1.5 w-full overflow-hidden rounded-full bg-muted">
<div <div className="h-full rounded-full bg-blue-400" style={{ width: `${pct}%` }} />
className="h-full rounded-full bg-blue-400"
style={{ width: `${pct}%` }}
/>
</div> </div>
<div className="flex justify-end text-xs tabular-nums text-muted-foreground"> <div className="flex justify-end text-xs tabular-nums text-muted-foreground">
<ConstructionEta completesAt={completesAt} /> <ConstructionEta completesAt={completesAt} />
@ -100,6 +123,8 @@ export function ConstructionPanel({
</p> </p>
)} )}
{crewLine && <p className={`text-xs ${crewLine.className}`}>{crewLine.text}</p>}
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
{materials.map((material) => { {materials.map((material) => {
const met = material.delivered >= material.required; const met = material.delivered >= material.required;

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

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

View file

@ -29,7 +29,13 @@ import {
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { useGameTick } from "@/hooks/useGameTick"; 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 { import {
findSnap, findSnap,
segmentMidpoints, 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 // Layer styling is hoisted to module constants: react-leaflet re-applies
// pathOptions by reference, and a fresh object literal every render forces a // pathOptions by reference, and a fresh object literal every render forces a
// canvas redraw that glitches mid-zoom-animation. // 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 }, water: { color: "#38bdf8", fillColor: "#38bdf8", fillOpacity: 0.25, weight: 1 },
land: { color: "#4ade80", fillColor: "#4ade80", fillOpacity: 0.08, weight: 1 }, land: { color: "#4ade80", fillColor: "#4ade80", fillOpacity: 0.08, weight: 1 },
territory: { color: "#fbbf24", fillColor: "#fbbf24", fillOpacity: 0.1, weight: 1 }, territory: { color: "#fbbf24", fillColor: "#fbbf24", fillOpacity: 0.1, weight: 1 },
border: { color: "#a78bfa", fillColor: "#a78bfa", fillOpacity: 0.06, 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> = { 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()} / ${structure.storageCapacityKg.toLocaleString()} kg`
: `${Math.round(structure.storageUsedKg).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"> <div className="border-t border-white/15 px-3 py-2">
@ -872,6 +890,8 @@ const StructureLayer = memo(function StructureLayer({
ribbonMeta = first ribbonMeta = first
? `Needs ${first.resourceName} x${first.amount ?? 0}${structure.materials.length > 1 ? ` +${structure.materials.length - 1}` : ""}` ? `Needs ${first.resourceName} x${first.amount ?? 0}${structure.materials.length > 1 ? ` +${structure.materials.length - 1}` : ""}`
: "Awaiting materials"; : "Awaiting materials";
} else if ((structure.compoundChildren ?? 0) > 0) {
ribbonMeta = `Compound: ${structure.compoundChildren} building${structure.compoundChildren === 1 ? "" : "s"}`;
} else { } else {
ribbonMeta = structure.blueprintName ?? "Structure"; ribbonMeta = structure.blueprintName ?? "Structure";
} }
@ -1487,7 +1507,7 @@ export function MapClient({ maps, canEdit, canPlace }: MapClientProps) {
zone.name && zone.labelVisible !== false ? ( zone.name && zone.labelVisible !== false ? (
<Marker <Marker
key={`zone-label-${zone.id}`} key={`zone-label-${zone.id}`}
position={toLatLng(polygonCentroid(zone.points))} position={toLatLng(zoneLabelPoint(zone.points) ?? polygonCentroid(zone.points))}
icon={zoneLabelIcon(zone.name)} icon={zoneLabelIcon(zone.name)}
interactive={false} interactive={false}
/> />
@ -1621,7 +1641,7 @@ export function MapClient({ maps, canEdit, canPlace }: MapClientProps) {
<Polygon <Polygon
positions={authoring.draftPoints.map(toLatLng)} positions={authoring.draftPoints.map(toLatLng)}
pathOptions={{ pathOptions={{
color: authoring.fields.zoneType === "water" ? "#38bdf8" : "#4ade80", color: ZONE_PATH_OPTIONS[authoring.fields.zoneType].color ?? "#4ade80",
fillOpacity: 0.1, fillOpacity: 0.1,
dashArray: "4 6", dashArray: "4 6",
}} }}

View file

@ -66,8 +66,22 @@ export function PlacementDialog({
const [blueprintId, setBlueprintId] = useState<string>(""); const [blueprintId, setBlueprintId] = useState<string>("");
const [name, setName] = useState(""); const [name, setName] = useState("");
const [factionId, setFactionId] = useState<string>("none"); const [factionId, setFactionId] = useState<string>("none");
const [parentStructureId, setParentStructureId] = useState<string>("none");
const [submitting, setSubmitting] = useState(false); 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(() => { useEffect(() => {
if (!open) return; if (!open) return;
let cancelled = false; let cancelled = false;
@ -196,6 +210,7 @@ export function PlacementDialog({
setBlueprintId(""); setBlueprintId("");
setName(""); setName("");
setFactionId("none"); setFactionId("none");
setParentStructureId("none");
}; };
const submit = async () => { const submit = async () => {
@ -207,6 +222,7 @@ export function PlacementDialog({
blueprintId: blueprint.id, blueprintId: blueprint.id,
name, name,
factionId: factionId === "none" ? null : Number(factionId), factionId: factionId === "none" ? null : Number(factionId),
parentStructureId: parentStructureId === "none" ? null : Number(parentStructureId),
x: point[0], x: point[0],
y: point[1], y: point[1],
}); });
@ -287,6 +303,30 @@ export function PlacementDialog({
/> />
</div> </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 && ( {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 className="rounded-md border border-white/10 bg-black/30 p-3 text-xs text-white/70 flex flex-col gap-1">
<div> <div>

View file

@ -10,15 +10,12 @@ import { StructureStorageView } from "@/components/frontend/storage/StructureSto
import { StaffRoster } from "@/components/frontend/baseManagement/StaffRoster"; import { StaffRoster } from "@/components/frontend/baseManagement/StaffRoster";
import { UpgradeCard } from "@/components/frontend/baseManagement/UpgradeCard"; import { UpgradeCard } from "@/components/frontend/baseManagement/UpgradeCard";
import { ConstructionPanel } from "@/components/frontend/baseManagement/ConstructionPanel"; 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 { getStructureDetail } from "@/app/(frontend)/logistics/structures/[id]/detail";
import type { StructureDetailData } from "@/app/(frontend)/logistics/structures/[id]/detail-data"; import type { StructureDetailData } from "@/app/(frontend)/logistics/structures/[id]/detail-data";
import { useGameTick } from "@/hooks/useGameTick"; import { useGameTick } from "@/hooks/useGameTick";
import { import { BoxIcon, CoinsIcon, MapPinIcon, WrenchIcon } from "lucide-react";
BoxIcon,
CoinsIcon,
MapPinIcon,
WrenchIcon,
} from "lucide-react";
import { import {
Item, Item,
ItemContent, ItemContent,
@ -92,7 +89,11 @@ export function StructureDetailDrawer({
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
{data.imageUrl && ( {data.imageUrl && (
<div className="relative overflow-hidden rounded-none border border-white/15"> <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 className="absolute inset-0 bg-gradient-to-t from-black/70 via-transparent to-transparent" />
</div> </div>
)} )}
@ -145,7 +146,9 @@ export function StructureDetailDrawer({
</ItemHeader> </ItemHeader>
<ItemContent> <ItemContent>
<div className="flex items-center gap-2"> <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> <span className="text-xs text-muted-foreground">base value</span>
</div> </div>
</ItemContent> </ItemContent>
@ -213,6 +216,25 @@ export function StructureDetailDrawer({
completesAt={structure.constructionCompletesAt ?? null} completesAt={structure.constructionCompletesAt ?? null}
durationMinutes={blueprint.constructionDurationMinutes ?? null} durationMinutes={blueprint.constructionDurationMinutes ?? null}
materials={data.derived.constructionMaterials} 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 <StaffRoster

View file

@ -18,6 +18,7 @@ import {
getGridSlots, getGridSlots,
getResourceCount, getResourceCount,
} from "@/lib/logistics"; } from "@/lib/logistics";
import { installedModuleEffects } from "@/lib/base/modules";
import { Faction, GameStructure, Structure } from "@/payload-types"; import { Faction, GameStructure, Structure } from "@/payload-types";
import { BoxIcon, Building2Icon, ChevronRightIcon, Grid3x3Icon, MapPinIcon } from "lucide-react"; import { BoxIcon, Building2Icon, ChevronRightIcon, Grid3x3Icon, MapPinIcon } from "lucide-react";
@ -33,9 +34,17 @@ export function StructureCard({ structure }: StructureCardProps) {
const storedResources = structure.storedResources ?? []; const storedResources = structure.storedResources ?? [];
const currentMass = calculateStoredMass(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 resourceCount = getResourceCount(storedResources);
const gridSlots = getGridSlots(structureType); const gridSlots = getGridSlots(structureType, moduleEffects.storageSlots);
const functionLabel = getFunctionTypeLabel(structureType.functionType); const functionLabel = getFunctionTypeLabel(structureType.functionType);
return ( return (

47
src/lib/base/adjacency.ts Normal file
View 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;
}

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

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

View file

@ -3,6 +3,7 @@ import type { NpcStaffing } from "@/payload-types";
import { emitGameEvent } from "@/utils/event-log/emit"; import { emitGameEvent } from "@/utils/event-log/emit";
import { EventTypes } from "@/utils/event-log/eventTypes"; import { EventTypes } from "@/utils/event-log/eventTypes";
import { relationshipId } from "./storage"; import { relationshipId } from "./storage";
import { applyCrewRateChange } from "./constructionSpeed";
import type { HireStaffInput, SetLaborHeadcountInput } from "./types"; import type { HireStaffInput, SetLaborHeadcountInput } from "./types";
export async function assertStaffHiringEnabled(payload: Payload): Promise<void> { export async function assertStaffHiringEnabled(payload: Payload): Promise<void> {
@ -226,6 +227,7 @@ export async function setLaborHeadcount(
targetCollection: "npc-staffing", targetCollection: "npc-staffing",
targetId: staffing.id, targetId: staffing.id,
}); });
await applyCrewRateChange(payload, structure.id, classification.id, currentHeadcount);
} }
return { id: null }; return { id: null };
} }
@ -272,6 +274,7 @@ export async function setLaborHeadcount(
unitSalary, unitSalary,
}, },
}); });
await applyCrewRateChange(payload, structure.id, classification.id, currentHeadcount);
return { id: updated.id }; return { id: updated.id };
} }

38
src/lib/map/compounds.ts Normal file
View 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 };
}

File diff suppressed because it is too large Load diff

View 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";`)
}

File diff suppressed because it is too large Load diff

View file

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

File diff suppressed because it is too large Load diff

View 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";`)
}

File diff suppressed because it is too large Load diff

View 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";`)
}

File diff suppressed because it is too large Load diff

View 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";`)
}

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

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

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

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