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,
upgradeStructure as upgradeStructureService,
} from "@/lib/base";
import {
installModule as installModuleService,
uninstallModule as uninstallModuleService,
} from "@/lib/base/modules";
import { emitGameEvent } from "@/utils/event-log/emit";
import { EventTypes } from "@/utils/event-log/eventTypes";
import { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQualification";
import { hasPermission } from "@/utils/access-control/hasPermission";
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
@ -81,6 +87,72 @@ export async function fireStaff(staffingId: number): Promise<ActionResult<null>>
}
}
export async function toggleProduction(
gameStructureId: number,
paused: boolean,
): Promise<ActionResult<{ paused: boolean }>> {
try {
const { payload, user } = await authenticate();
if (!(await hasLogisticsQualification(payload, user))) {
return { success: false, error: "Insufficient permissions." };
}
const structure = await payload.findByID({
collection: "game-structures",
id: gameStructureId,
depth: 0,
overrideAccess: true,
});
await payload.update({
collection: "game-structures",
id: gameStructureId,
data: { productionPaused: paused },
overrideAccess: true,
});
await emitGameEvent(payload, {
type: EventTypes.ProductionToggled,
message: `${structure.name} production ${paused ? "paused" : "resumed"} by ${user.username ?? "logistics"}`,
structure: gameStructureId,
targetCollection: "game-structures",
targetId: gameStructureId,
});
return { success: true, data: { paused } };
} catch (error) {
return actionError(error);
}
}
export async function installModule(
gameStructureId: number,
moduleName: string,
): Promise<ActionResult<{ installed: { name: string; installedAt: string | null }[] }>> {
try {
const { payload, user } = await authenticate();
if (!(await hasLogisticsQualification(payload, user))) {
return { success: false, error: "Insufficient permissions." };
}
const installed = await installModuleService(payload, gameStructureId, moduleName);
return { success: true, data: { installed } };
} catch (error) {
return actionError(error);
}
}
export async function uninstallModule(
gameStructureId: number,
moduleName: string,
): Promise<ActionResult<{ installed: { name: string; installedAt: string | null }[] }>> {
try {
const { payload, user } = await authenticate();
if (!(await hasLogisticsQualification(payload, user))) {
return { success: false, error: "Insufficient permissions." };
}
const installed = await uninstallModuleService(payload, gameStructureId, moduleName);
return { success: true, data: { installed } };
} catch (error) {
return actionError(error);
}
}
export async function setLaborHeadcount(
structureId: number,
classificationId: number,

View file

@ -46,6 +46,7 @@ export async function placeStructure(params: {
blueprintId: number;
name: string;
factionId?: number | null;
parentStructureId?: number | null;
x: number;
y: number;
}): Promise<ActionResult> {
@ -101,6 +102,27 @@ export async function placeStructure(params: {
}
}
let parentStructureId: number | null = null;
if (params.parentStructureId != null) {
const hub = await payload
.findByID({
collection: "game-structures",
id: params.parentStructureId,
depth: 0,
})
.catch(() => null);
if (!hub) {
return { success: false, error: "Compound hub not found." };
}
if ((hub as unknown as GameStructure).map !== mapId) {
return { success: false, error: "The compound hub is on a different map." };
}
if ((hub as unknown as GameStructure).parentStructure != null) {
return { success: false, error: "Compound hubs cannot themselves be compound children." };
}
parentStructureId = hub.id;
}
const [zonesRes, nodesRes, structuresRes] = await Promise.all([
payload.find({
collection: "map-zones",
@ -184,6 +206,7 @@ export async function placeStructure(params: {
map: mapId,
coordinates: point,
constructionStatus: "awaiting_materials",
parentStructure: parentStructureId,
},
});
@ -240,7 +263,7 @@ export interface MapZoneInput {
mapId: number;
name?: string | null;
description?: string | null;
type: "water" | "land" | "territory" | "border";
type: "water" | "land" | "territory" | "border" | "other";
resourceIds?: number[];
fillColor?: string | null;
strokeColor?: string | null;

View file

@ -1,12 +1,10 @@
import { getPayload, type Payload } from "payload";
import type { Faction, GameStructure, Structure } from "@/payload-types";
import { convertLexicalToPlaintext } from "@payloadcms/richtext-lexical/plaintext";
import {
calculateStoredMass,
calculateUtilization,
getGridSlots,
} from "@/lib/logistics";
import { calculateStoredMass, calculateUtilization, getGridSlots } from "@/lib/logistics";
import type { LaborClassificationOption, StructureStaffingOption } from "@/lib/base";
import { effectiveCrew } from "@/lib/base/constructionSpeed";
import { installedModuleEffects } from "@/lib/base/modules";
import { currencyConfigFromGameRules } from "@/lib/banking";
import { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQualification";
import { isSuperuser } from "@/utils/access-control/hasPermission";
@ -22,11 +20,29 @@ export interface StructureDetailData {
gridSlots: number | null;
totalValue: number;
constructionMaterials: { name: string; required: number; delivered: number }[];
constructionCrew: { required: number; effective: number };
};
upgrade: {
targetName: string;
materials: { name: string; needed: number; have: number }[];
} | null;
production: {
paused: boolean;
stallReason: string | null;
inputs: { name: string; amountPerHour: number }[];
outputs: { name: string; amountPerHour: number }[];
};
modules: {
slots: number;
installed: { name: string; installedAt: string | null }[];
options: {
name: string;
category: string;
description: string | null;
effects: { type: string; value: number }[];
materials: { name: string; amount: number }[];
}[];
};
staffing: {
structureStaffing: StructureStaffingOption;
staff: {
@ -76,8 +92,16 @@ export async function fetchStructureDetail(
const structureType = structure.type;
const storedResources = structure.storedResources ?? [];
const currentMass = calculateStoredMass(storedResources);
const utilization = calculateUtilization(structureType, storedResources);
const gridSlots = getGridSlots(structureType);
const moduleEffects = installedModuleEffects(
structureType,
(structure.installedModules ?? []).map((entry) => entry.name),
);
const utilization = calculateUtilization(
structureType,
storedResources,
moduleEffects.capacityMass,
);
const gridSlots = getGridSlots(structureType, moduleEffects.storageSlots);
const description = structureType.description
? convertLexicalToPlaintext({ data: structureType.description })
: null;
@ -194,6 +218,9 @@ export async function fetchStructureDetail(
limit: 100,
depth: 0,
});
const buildEfficiencyById = new Map(
classificationsRes.docs.map((doc) => [doc.id, doc.buildEfficiency ?? 1]),
);
const classifications: LaborClassificationOption[] = classificationsRes.docs.map((doc) => ({
id: doc.id,
name: doc.name,
@ -274,13 +301,77 @@ export async function fetchStructureDetail(
};
});
const constructionCrew = {
required: structureType.constructionCrewRequired ?? 0,
effective: effectiveCrew(
staff
.filter(
(member) => member.positionType === "labor" && member.laborClassificationId !== null,
)
.map((member) => ({
classificationId: member.laborClassificationId as number,
headcount: member.headcount,
efficiency: buildEfficiencyById.get(member.laborClassificationId as number) ?? 1,
})),
),
};
const recipeLine = (line: {
resource: number | { id: number; name: string };
amountPerHour?: number | null;
}): { name: string; amountPerHour: number } => {
const resource = line.resource;
const name =
typeof resource === "object" && resource !== null ? resource.name : `Resource #${resource}`;
return { name, amountPerHour: line.amountPerHour ?? 0 };
};
const production = {
paused: Boolean(structure.productionPaused),
stallReason: structure.productionStallReason ?? null,
inputs: (structure.productionInputs ?? []).map(recipeLine),
outputs: (structure.productionOutputs ?? []).map(recipeLine),
};
const modules = {
slots: structureType.moduleSlots ?? 0,
installed: (structure.installedModules ?? []).map((entry) => ({
name: entry.name,
installedAt: entry.installedAt ?? null,
})),
options: (structureType.moduleOptions ?? []).map((option) => ({
name: option.name,
category: option.category,
description: option.description ?? null,
effects: (option.effects ?? []).map((effect) => ({
type: effect.type ?? "",
value: effect.value ?? 0,
})),
materials: (option.materials ?? []).map((material) => ({
name:
typeof material.resource === "object" && material.resource !== null
? material.resource.name
: `Resource #${material.resource}`,
amount: Math.ceil(material.amount ?? 0),
})),
})),
};
return {
structure,
blueprint: structureType,
description,
imageUrl,
derived: { currentMass, utilization, gridSlots, totalValue, constructionMaterials },
derived: {
currentMass,
utilization,
gridSlots,
totalValue,
constructionMaterials,
constructionCrew,
},
upgrade,
production,
modules,
staffing: {
structureStaffing,
staff,

View file

@ -10,6 +10,8 @@ import { StorageRules } from "@/components/frontend/storage/StorageRules";
import { StructureStorageView } from "@/components/frontend/storage/StructureStorageView";
import { StaffRoster } from "@/components/frontend/baseManagement/StaffRoster";
import { UpgradeCard } from "@/components/frontend/baseManagement/UpgradeCard";
import { ProductionPanel } from "@/components/frontend/baseManagement/ProductionPanel";
import { SlotsPanel } from "@/components/frontend/baseManagement/SlotsPanel";
import { ConstructionPanel } from "@/components/frontend/baseManagement/ConstructionPanel";
import {
calculateStoredMass,
@ -20,6 +22,8 @@ import {
} from "@/lib/logistics";
import { pluralize } from "@/lib/utils";
import type { LaborClassificationOption, StructureStaffingOption } from "@/lib/base";
import { effectiveCrew } from "@/lib/base/constructionSpeed";
import { installedModuleEffects } from "@/lib/base/modules";
import { currencyConfigFromGameRules } from "@/lib/banking";
import { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQualification";
import { isSuperuser } from "@/utils/access-control/hasPermission";
@ -71,9 +75,33 @@ export default async function StructureDetailPage({ params }: StructureDetailPag
const structureType = structure.type;
const storedResources = structure.storedResources ?? [];
const compoundHub =
typeof structure.parentStructure === "object" && structure.parentStructure !== null
? structure.parentStructure
: null;
const compoundChildren = (
await payload.find({
collection: "game-structures",
where: { parentStructure: { equals: structure.id } },
limit: 50,
depth: 0,
})
).docs.map((doc) => ({
id: doc.id,
name: doc.name,
constructionStatus: doc.constructionStatus ?? "complete",
}));
const currentMass = calculateStoredMass(storedResources);
const utilization = calculateUtilization(structureType, storedResources);
const gridSlots = getGridSlots(structureType);
const moduleEffects = installedModuleEffects(
structureType,
(structure.installedModules ?? []).map((entry) => entry.name),
);
const utilization = calculateUtilization(
structureType,
storedResources,
moduleEffects.capacityMass,
);
const gridSlots = getGridSlots(structureType, moduleEffects.storageSlots);
const functionLabel = getFunctionTypeLabel(structureType.functionType);
const description = structureType.description
? convertLexicalToPlaintext({ data: structureType.description })
@ -193,6 +221,9 @@ export default async function StructureDetailPage({ params }: StructureDetailPag
limit: 100,
depth: 0,
});
const buildEfficiencyById = new Map(
classificationsRes.docs.map((doc) => [doc.id, doc.buildEfficiency ?? 1]),
);
const classifications: LaborClassificationOption[] = classificationsRes.docs.map((doc) => ({
id: doc.id,
name: doc.name,
@ -200,6 +231,20 @@ export default async function StructureDetailPage({ params }: StructureDetailPag
defaultUnitSalary: doc.defaultUnitSalary ?? null,
availableHeadcount: doc.availableHeadcount ?? 0,
}));
const constructionCrew = {
required: structureType.constructionCrewRequired ?? 0,
effective: effectiveCrew(
staff
.filter(
(member) => member.positionType === "labor" && member.laborClassificationId !== null,
)
.map((member) => ({
classificationId: member.laborClassificationId as number,
headcount: member.headcount,
efficiency: buildEfficiencyById.get(member.laborClassificationId as number) ?? 1,
})),
),
};
const rules = await payload.findGlobal({
slug: "game-rules",
@ -434,6 +479,45 @@ export default async function StructureDetailPage({ params }: StructureDetailPag
)}
</div>
{(compoundHub || compoundChildren.length > 0) && (
<div className="rounded-lg border border-border p-5 flex flex-col gap-2">
<h2 className="text-sm font-semibold">Compound</h2>
{compoundHub && (
<p className="text-sm text-muted-foreground">
Part of{" "}
<a
className="text-foreground underline underline-offset-2 hover:text-primary"
href={`/logistics/structures/${compoundHub.id}`}
>
{compoundHub.name}
</a>
{"; storage pools into the hub while this building is operational."}
</p>
)}
{compoundChildren.length > 0 && (
<div className="flex flex-col gap-1">
<span className="text-xs uppercase tracking-wide text-muted-foreground">
{compoundChildren.length} child building{compoundChildren.length === 1 ? "" : "s"}
</span>
<div className="flex flex-wrap gap-2">
{compoundChildren.map((child) => (
<a
key={child.id}
href={`/logistics/structures/${child.id}`}
className="rounded-md border border-border px-2 py-1 text-xs hover:bg-muted"
>
{child.name}
<span className="ml-1 text-muted-foreground">
{child.constructionStatus.replace(/_/g, " ")}
</span>
</a>
))}
</div>
</div>
)}
</div>
)}
<UpgradeCard gameStructureId={structure.id} upgrade={upgrade} />
<ConstructionPanel
@ -442,6 +526,55 @@ export default async function StructureDetailPage({ params }: StructureDetailPag
completesAt={structure.constructionCompletesAt ?? null}
durationMinutes={structureType.constructionDurationMinutes ?? null}
materials={constructionMaterials}
crew={constructionCrew}
/>
<ProductionPanel
gameStructureId={structure.id}
complete={(structure.constructionStatus ?? "complete") === "complete"}
paused={Boolean(structure.productionPaused)}
stallReason={structure.productionStallReason ?? null}
inputs={(structure.productionInputs ?? []).map((line) => ({
name:
typeof line.resource === "object" && line.resource !== null
? line.resource.name
: `Resource #${line.resource}`,
amountPerHour: line.amountPerHour ?? 0,
}))}
outputs={(structure.productionOutputs ?? []).map((line) => ({
name:
typeof line.resource === "object" && line.resource !== null
? line.resource.name
: `Resource #${line.resource}`,
amountPerHour: line.amountPerHour ?? 0,
}))}
canManage={canManage}
/>
<SlotsPanel
gameStructureId={structure.id}
slots={structureType.moduleSlots ?? 0}
installed={(structure.installedModules ?? []).map((entry) => ({
name: entry.name,
installedAt: entry.installedAt ?? null,
}))}
options={(structureType.moduleOptions ?? []).map((option) => ({
name: option.name,
category: option.category,
description: option.description ?? null,
effects: (option.effects ?? []).map((effect) => ({
type: effect.type ?? "",
value: effect.value ?? 0,
})),
materials: (option.materials ?? []).map((material) => ({
name:
typeof material.resource === "object" && material.resource !== null
? material.resource.name
: `Resource #${material.resource}`,
amount: Math.ceil(material.amount ?? 0),
})),
}))}
canManage={canManage}
/>
<StaffRoster

View file

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

View file

@ -2,6 +2,12 @@ import { NextRequest, NextResponse } from "next/server";
import config from "@payload-config";
import { getPayload } from "payload";
import type { GameStructure, Map as MapDoc, Shipment } from "@/payload-types";
import { effectiveMaxMass } from "@/lib/base/modules";
import {
aggregateCompoundStorage,
compoundParentId,
isOperationalMember,
} from "@/lib/map/compounds";
import { isPointArray, type MapPoint } from "@/lib/map/points";
import type { MapFeaturesResponse, StructureFeature } from "@/lib/map/types";
@ -132,9 +138,11 @@ export async function GET(req: NextRequest) {
constructionCompletesAt: structure.constructionCompletesAt ?? null,
constructionDurationMinutes: blueprint?.constructionDurationMinutes ?? null,
storageUsedKg,
storageCapacityKg: blueprint?.maxCapacityMass ?? null,
storageCapacityKg: blueprint ? effectiveMaxMass(blueprint, structure.installedModules) : null,
labelVisible: structure.labelVisible ?? null,
labelPriority: structure.labelPriority ?? null,
parentId: compoundParentId(structure),
compoundChildren: 0,
blueprintId: blueprint?.id ?? null,
blueprintName: blueprint?.name ?? null,
blueprintTerrain: (blueprint?.terrainType as "land" | "water" | undefined) ?? null,
@ -160,6 +168,26 @@ export async function GET(req: NextRequest) {
});
}
// Compound pooling: children fold their storage into the hub feature and lose
// their own map labels; the hub carries the compound label and ribbon totals.
const featuresById = new Map(structures.map((feature) => [feature.id, feature]));
const childrenByHub = new Map<number, StructureFeature[]>();
for (const feature of structures) {
if (feature.parentId === null) continue;
feature.labelVisible = false;
const siblings = childrenByHub.get(feature.parentId) ?? [];
siblings.push(feature);
childrenByHub.set(feature.parentId, siblings);
}
for (const [hubId, children] of childrenByHub) {
const hub = featuresById.get(hubId);
if (!hub) continue;
const totals = aggregateCompoundStorage(hub, children);
hub.storageUsedKg = totals.storageUsedKg;
hub.storageCapacityKg = totals.storageCapacityKg;
hub.compoundChildren = children.length;
}
const relEntries = (value: unknown): { id: number; name: string }[] =>
(Array.isArray(value) ? value : []).map((entry) =>
typeof entry === "object" && entry !== null && "id" in entry

View file

@ -3,6 +3,9 @@ import { requirePermission } from "@/utils/access-control/hasPermission";
import { emitGameEvent } from "@/utils/event-log/emit";
import { staffingDefaultsFromBlueprint } from "@/lib/base/staffingDefaults";
import { createStaffingFields } from "./staffingFields";
import { createProductionFields } from "./productionFields";
import { createModuleFields } from "./moduleFields";
import { productionDefaultsFromBlueprint } from "@/lib/base/productionDefaults";
export const GameStructures: CollectionConfig = {
slug: "game-structures",
@ -28,6 +31,7 @@ export const GameStructures: CollectionConfig = {
overrideAccess: true,
});
const defaults = staffingDefaultsFromBlueprint(blueprint);
const productionDefaults = productionDefaultsFromBlueprint(blueprint);
return {
...data,
@ -35,6 +39,12 @@ export const GameStructures: CollectionConfig = {
? { laborRequirements: defaults.laborRequirements }
: {}),
...(data.staffPositions === undefined ? { staffPositions: defaults.staffPositions } : {}),
...(data.productionInputs === undefined
? { productionInputs: productionDefaults.productionInputs }
: {}),
...(data.productionOutputs === undefined
? { productionOutputs: productionDefaults.productionOutputs }
: {}),
...(data.maintenanceCost === undefined
? { maintenanceCost: defaults.maintenanceCost }
: {}),
@ -127,7 +137,8 @@ export const GameStructures: CollectionConfig = {
type: "date",
label: "Construction Started At",
admin: {
description: "When construction began (materials consumed). Empty while awaiting materials.",
description:
"When construction began (materials consumed). Empty while awaiting materials.",
},
},
{
@ -139,6 +150,30 @@ export const GameStructures: CollectionConfig = {
},
},
...createStaffingFields("instance"),
...createProductionFields("instance"),
...createModuleFields("instance"),
{
name: "productionPaused",
type: "checkbox",
defaultValue: false,
admin: {
description: "When checked the structure stops producing until unchecked.",
},
},
{
name: "lastProductionAt",
type: "date",
admin: { readOnly: true, description: "Last game tick that ran this structure's recipe." },
},
{
name: "productionStallReason",
type: "text",
admin: {
readOnly: true,
description:
"Why production is not running: no_labor, missing_inputs, storage_full. Empty = running.",
},
},
{
label: "Geolocation",
type: "group",
@ -174,6 +209,16 @@ export const GameStructures: CollectionConfig = {
"Label priority for clustering (0-100). Higher wins when structures overlap at low zoom; ties break by stored mass.",
},
},
{
name: "parentStructure",
type: "relationship",
relationTo: "game-structures",
label: "Compound Hub",
admin: {
description:
"Set when this building is part of a compound: its storage pools into the hub, which carries the compound's map label and ribbon.",
},
},
{
name: "storedResources",
type: "array",

View file

@ -52,6 +52,17 @@ export const LaborClassifications: CollectionConfig = {
type: "textarea",
label: "Description",
},
{
name: "buildEfficiency",
type: "number",
min: 0,
defaultValue: 1,
label: "Build efficiency",
admin: {
description:
"Construction speed this trade contributes per worker while building structures (1 = a full worker).",
},
},
{
name: "availableHeadcount",
type: "number",

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 { emitGameEvent } from "@/utils/event-log/emit";
import { createStaffingFields } from "@/collections/game/staffingFields";
import { createProductionFields } from "@/collections/game/productionFields";
import { createModuleFields } from "@/collections/game/moduleFields";
import { createAuraFields } from "@/collections/game/auraFields";
export const Structures: CollectionConfig = {
slug: "structures",
@ -359,6 +362,17 @@ export const Structures: CollectionConfig = {
"How many real minutes it takes to complete construction of this structure after materials are delivered and building starts.",
},
},
{
name: "constructionCrewRequired",
type: "number",
min: 0,
defaultValue: 0,
label: "Construction Crew Required",
admin: {
description:
"Effective crew needed for the listed build time. 0 builds at full speed with no labor. Understaffed sites build proportionally slower.",
},
},
{
name: "requiredTech",
type: "relationship",
@ -498,6 +512,17 @@ export const Structures: CollectionConfig = {
type: "group",
fields: createStaffingFields("defaults"),
},
{
label: "Production Recipe Defaults",
type: "group",
fields: createProductionFields("defaults"),
},
{
label: "Modules",
type: "group",
fields: createModuleFields("defaults"),
},
...createAuraFields(),
{
label: "Production & Function",

View file

@ -15,6 +15,7 @@ interface ConstructionPanelProps {
completesAt: string | null;
durationMinutes: number | null;
materials: ConstructionMaterial[];
crew?: { required: number; effective: number } | null;
}
const STATUS_LABELS: Record<string, { label: string; className: string }> = {
@ -48,6 +49,7 @@ export function ConstructionPanel({
completesAt,
durationMinutes,
materials,
crew,
}: ConstructionPanelProps) {
const [nowMs, setNowMs] = useState(() => Date.now());
@ -62,6 +64,30 @@ export function ConstructionPanel({
const allDelivered = materials.every((material) => material.delivered >= material.required);
const pct = status === "building" ? progressPct(startedAt, completesAt, nowMs) : 0;
const crewRequired = crew?.required ?? 0;
const crewEffective = crew?.effective ?? 0;
const crewFactor = crewRequired > 0 ? Math.min(1, crewEffective / crewRequired) : 1;
const crewLine =
crewRequired > 0
? crewEffective <= 0
? {
text:
status === "building"
? "No crew assigned: construction is crawling at minimum speed."
: "No crew assigned: construction cannot start once materials arrive.",
className: "text-amber-300",
}
: crewFactor < 1
? {
text: `Crew ${crewEffective.toLocaleString()} / ${crewRequired.toLocaleString()}: understaffed, building at ${Math.round(crewFactor * 100)}% speed.`,
className: "text-amber-300",
}
: {
text: `Crew ${crewEffective.toLocaleString()} / ${crewRequired.toLocaleString()}: full build speed.`,
className: "text-emerald-400",
}
: null;
return (
<div className="rounded-lg border border-border p-5 flex flex-col gap-3">
<div className="flex items-center justify-between gap-2">
@ -82,10 +108,7 @@ export function ConstructionPanel({
<span>{pct}%</span>
</div>
<div className="h-1.5 w-full overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full bg-blue-400"
style={{ width: `${pct}%` }}
/>
<div className="h-full rounded-full bg-blue-400" style={{ width: `${pct}%` }} />
</div>
<div className="flex justify-end text-xs tabular-nums text-muted-foreground">
<ConstructionEta completesAt={completesAt} />
@ -100,6 +123,8 @@ export function ConstructionPanel({
</p>
)}
{crewLine && <p className={`text-xs ${crewLine.className}`}>{crewLine.text}</p>}
<div className="flex flex-col gap-1.5">
{materials.map((material) => {
const met = material.delivered >= material.required;

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

View file

@ -66,8 +66,22 @@ export function PlacementDialog({
const [blueprintId, setBlueprintId] = useState<string>("");
const [name, setName] = useState("");
const [factionId, setFactionId] = useState<string>("none");
const [parentStructureId, setParentStructureId] = useState<string>("none");
const [submitting, setSubmitting] = useState(false);
// Compound hubs: operational top-level structures only (children cannot be hubs).
const hubOptions = useMemo(
() =>
structures
.filter(
(structure) =>
(structure.constructionStatus ?? "complete") === "complete" &&
structure.parentId === null,
)
.map((structure) => ({ id: structure.id, name: structure.name })),
[structures],
);
useEffect(() => {
if (!open) return;
let cancelled = false;
@ -196,6 +210,7 @@ export function PlacementDialog({
setBlueprintId("");
setName("");
setFactionId("none");
setParentStructureId("none");
};
const submit = async () => {
@ -207,6 +222,7 @@ export function PlacementDialog({
blueprintId: blueprint.id,
name,
factionId: factionId === "none" ? null : Number(factionId),
parentStructureId: parentStructureId === "none" ? null : Number(parentStructureId),
x: point[0],
y: point[1],
});
@ -287,6 +303,30 @@ export function PlacementDialog({
/>
</div>
{hubOptions.length > 0 && (
<div className="flex flex-col gap-1.5">
<Label htmlFor="place-parent">Compound (optional)</Label>
<Combobox
id="place-parent"
value={parentStructureId}
onValueChange={setParentStructureId}
searchPlaceholder="Search compounds..."
emptyMessage="No compound hubs available."
options={[
{ value: "none", label: "Standalone building" },
...hubOptions.map((hub) => ({
value: String(hub.id),
label: hub.name,
})),
]}
/>
<p className="text-xs text-muted-foreground">
Child buildings pool their storage into the hub once operational; the hub carries
the compound's map label.
</p>
</div>
)}
{blueprint && (
<div className="rounded-md border border-white/10 bg-black/30 p-3 text-xs text-white/70 flex flex-col gap-1">
<div>

View file

@ -10,15 +10,12 @@ import { StructureStorageView } from "@/components/frontend/storage/StructureSto
import { StaffRoster } from "@/components/frontend/baseManagement/StaffRoster";
import { UpgradeCard } from "@/components/frontend/baseManagement/UpgradeCard";
import { ConstructionPanel } from "@/components/frontend/baseManagement/ConstructionPanel";
import { ProductionPanel } from "@/components/frontend/baseManagement/ProductionPanel";
import { SlotsPanel } from "@/components/frontend/baseManagement/SlotsPanel";
import { getStructureDetail } from "@/app/(frontend)/logistics/structures/[id]/detail";
import type { StructureDetailData } from "@/app/(frontend)/logistics/structures/[id]/detail-data";
import { useGameTick } from "@/hooks/useGameTick";
import {
BoxIcon,
CoinsIcon,
MapPinIcon,
WrenchIcon,
} from "lucide-react";
import { BoxIcon, CoinsIcon, MapPinIcon, WrenchIcon } from "lucide-react";
import {
Item,
ItemContent,
@ -92,7 +89,11 @@ export function StructureDetailDrawer({
<div className="flex flex-col gap-2">
{data.imageUrl && (
<div className="relative overflow-hidden rounded-none border border-white/15">
<img src={data.imageUrl} alt={structure.name} className="h-36 w-full object-cover" />
<img
src={data.imageUrl}
alt={structure.name}
className="h-36 w-full object-cover"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-transparent to-transparent" />
</div>
)}
@ -145,7 +146,9 @@ export function StructureDetailDrawer({
</ItemHeader>
<ItemContent>
<div className="flex items-center gap-2">
<span className="text-xl font-bold">{data.derived.totalValue.toLocaleString()}</span>
<span className="text-xl font-bold">
{data.derived.totalValue.toLocaleString()}
</span>
<span className="text-xs text-muted-foreground">base value</span>
</div>
</ItemContent>
@ -213,6 +216,25 @@ export function StructureDetailDrawer({
completesAt={structure.constructionCompletesAt ?? null}
durationMinutes={blueprint.constructionDurationMinutes ?? null}
materials={data.derived.constructionMaterials}
crew={data.derived.constructionCrew}
/>
<ProductionPanel
gameStructureId={structure.id}
complete={(structure.constructionStatus ?? "complete") === "complete"}
paused={data.production.paused}
stallReason={data.production.stallReason}
inputs={data.production.inputs}
outputs={data.production.outputs}
canManage={data.gates.canManage}
/>
<SlotsPanel
gameStructureId={structure.id}
slots={data.modules.slots}
installed={data.modules.installed}
options={data.modules.options}
canManage={data.gates.canManage}
/>
<StaffRoster

View file

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

47
src/lib/base/adjacency.ts Normal file
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 { EventTypes } from "@/utils/event-log/eventTypes";
import { relationshipId } from "./storage";
import { applyCrewRateChange } from "./constructionSpeed";
import type { HireStaffInput, SetLaborHeadcountInput } from "./types";
export async function assertStaffHiringEnabled(payload: Payload): Promise<void> {
@ -226,6 +227,7 @@ export async function setLaborHeadcount(
targetCollection: "npc-staffing",
targetId: staffing.id,
});
await applyCrewRateChange(payload, structure.id, classification.id, currentHeadcount);
}
return { id: null };
}
@ -272,6 +274,7 @@ export async function setLaborHeadcount(
unitSalary,
},
});
await applyCrewRateChange(payload, structure.id, classification.id, currentHeadcount);
return { id: updated.id };
}

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