1
0
Fork 0
polaris-task-force/tests/int/production.int.spec.ts
2026-09-20 02:43:23 -04:00

327 lines
10 KiB
TypeScript

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