Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
364 lines
12 KiB
TypeScript
364 lines
12 KiB
TypeScript
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(),
|
|
);
|
|
});
|
|
});
|
|
});
|