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