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