1
0
Fork 0

feat(base): add staffing service library

Adds the lib/base service layer: hire/fire staff, set labor headcount, storage consumption helpers, and structure upgrade handling, all with event logging and permission checks.

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-06 01:24:08 -04:00
parent b2718b3633
commit c33d0ceb27
5 changed files with 532 additions and 0 deletions

11
src/lib/base/index.ts Normal file
View file

@ -0,0 +1,11 @@
export { upgradeStructure } from "./upgrade";
export { assertStaffHiringEnabled, fireStaff, hireStaff, setLaborHeadcount } from "./staffing";
export { processBaseTick } from "./tick";
export type {
BaseTickResult,
HireStaffInput,
LaborClassificationOption,
SetLaborHeadcountInput,
StaffingPositionType,
StructureStaffingOption,
} from "./types";

337
src/lib/base/staffing.ts Normal file
View file

@ -0,0 +1,337 @@
import type { Payload } from "payload";
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 type { HireStaffInput, SetLaborHeadcountInput } from "./types";
export async function assertStaffHiringEnabled(payload: Payload): Promise<void> {
const rules = await payload.findGlobal({
slug: "game-rules",
depth: 0,
overrideAccess: true,
});
if (!rules.staffHiringEnabled) {
throw new Error(
rules.staffHiringDisabledMessage?.trim() || "Staff hiring is currently disabled.",
);
}
}
function staffingPositionType(value: string): "administrative" | "technical" | "other" {
switch (value) {
case "administrative":
case "technical":
case "other":
return value;
default:
throw new Error("Invalid staff position type.");
}
}
export async function hireStaff(
payload: Payload,
input: HireStaffInput,
actorUserId?: number,
): Promise<NpcStaffing> {
await assertStaffHiringEnabled(payload);
if (input.positionType === "labor") {
throw new Error("Basic labor is tracked by headcount — use the labor count control instead.");
}
const positionType = staffingPositionType(input.positionType);
const npc = await payload
.findByID({ collection: "game-npcs", id: input.npcId, depth: 0, overrideAccess: true })
.catch(() => null);
if (!npc) throw new Error("NPC not found.");
const structure = await payload
.findByID({
collection: "game-structures",
id: input.structureId,
depth: 0,
overrideAccess: true,
})
.catch(() => null);
if (!structure) throw new Error("Structure not found.");
const positionRule = structure.staffPositions?.find(
(position) => position.positionType === positionType,
);
if (!positionRule) {
throw new Error(`This structure does not accept ${positionType} staff.`);
}
if (positionRule.max !== null && positionRule.max !== undefined) {
const activeAtStructure = await payload.find({
collection: "npc-staffing",
where: {
and: [
{ structure: { equals: structure.id } },
{ positionType: { equals: positionType } },
{ npc: { exists: true } },
{ status: { equals: "active" } },
],
},
limit: 0,
depth: 0,
overrideAccess: true,
});
if (activeAtStructure.totalDocs >= positionRule.max) {
throw new Error(`This structure has reached its ${positionType} staff limit.`);
}
}
const existing = await payload.find({
collection: "npc-staffing",
where: { and: [{ npc: { equals: npc.id } }, { status: { equals: "active" } }] },
limit: 1,
depth: 1,
overrideAccess: true,
});
if (existing.docs[0]) {
const assignment = existing.docs[0];
const assignedStructure = assignment.structure;
const structureName =
typeof assignedStructure === "object"
? assignedStructure.name
: `structure #${assignedStructure}`;
throw new Error(
`${npc.name} is already employed as ${assignment.positionTitle} at ${structureName}.`,
);
}
const staffing = await payload.create({
collection: "npc-staffing",
data: {
npc: npc.id,
structure: structure.id,
positionType,
positionTitle: input.positionTitle,
salary: positionRule.enforcedSalary ?? input.salary,
headcount: 1,
status: "active",
hiredAt: new Date().toISOString(),
},
overrideAccess: true,
});
await payload.update({
collection: "game-npcs",
id: npc.id,
data: { location: structure.id, isInGame: true },
overrideAccess: true,
});
await emitGameEvent(payload, {
type: EventTypes.StaffHired,
message: `${npc.name} hired as ${staffing.positionTitle} at ${structure.name}`,
actor: actorUserId,
structure: structure.id,
targetCollection: "npc-staffing",
targetId: staffing.id,
data: { salary: staffing.salary ?? 0, npcId: npc.id },
});
return staffing;
}
export async function setLaborHeadcount(
payload: Payload,
input: SetLaborHeadcountInput,
): Promise<{ readonly id: number | null }> {
await assertStaffHiringEnabled(payload);
if (!Number.isInteger(input.headcount) || input.headcount < 0) {
throw new Error("Headcount must be a whole number of 0 or more.");
}
if (!Number.isFinite(input.unitSalary) || input.unitSalary < 0) {
throw new Error("Wage cannot be negative.");
}
const classification = await payload
.findByID({
collection: "labor-classifications",
id: input.classificationId,
depth: 0,
overrideAccess: true,
})
.catch(() => null);
if (!classification) throw new Error("Unknown labor classification.");
const structure = await payload
.findByID({
collection: "game-structures",
id: input.structureId,
depth: 0,
overrideAccess: true,
})
.catch(() => null);
if (!structure) throw new Error("Structure not found.");
const requirement = structure.laborRequirements?.find(
(entry) => relationshipId(entry.classification) === classification.id,
);
if (!requirement) {
throw new Error(`This structure does not accept ${classification.name} crews.`);
}
const existing = await payload.find({
collection: "npc-staffing",
where: {
and: [
{ structure: { equals: structure.id } },
{ positionType: { equals: "labor" } },
{ laborClassification: { equals: classification.id } },
{ status: { equals: "active" } },
],
},
limit: 1,
depth: 0,
overrideAccess: true,
});
const staffing = existing.docs[0];
const classificationLabel = classification.name;
const currentHeadcount = staffing?.headcount ?? 0;
const maxHeadcount = requirement.maxHeadcount ?? 0;
const unitSalary = requirement.enforcedSalary ?? input.unitSalary;
if (input.headcount > maxHeadcount) {
throw new Error(
`${classificationLabel} crews are capped at ${maxHeadcount} for this structure.`,
);
}
const headcountDelta = input.headcount - currentHeadcount;
const availableHeadcount = classification.availableHeadcount ?? 0;
const marketRate = classification.defaultUnitSalary ?? 0;
const wageFactor = marketRate > 0 ? Math.min(1, unitSalary / marketRate) : 1;
const effectiveAvailable = Math.floor(availableHeadcount * wageFactor);
if (headcountDelta > effectiveAvailable) {
throw new Error(
`Only ${effectiveAvailable} ${classificationLabel} workers are available at this wage.`,
);
}
if (input.headcount <= 0) {
if (staffing) {
await payload.update({
collection: "npc-staffing",
id: staffing.id,
data: { status: "terminated", terminatedAt: new Date().toISOString() },
overrideAccess: true,
});
await payload.update({
collection: "labor-classifications",
id: classification.id,
data: { availableHeadcount: availableHeadcount + currentHeadcount },
overrideAccess: true,
});
await emitGameEvent(payload, {
type: EventTypes.StaffTerminated,
message: `${classificationLabel} crew released at ${structure.name}`,
structure: structure.id,
targetCollection: "npc-staffing",
targetId: staffing.id,
});
}
return { id: null };
}
const data = {
npc: null,
structure: structure.id,
positionType: "labor" as const,
laborClassification: classification.id,
positionTitle: `${classificationLabel} Labor`,
salary: unitSalary,
headcount: input.headcount,
status: "active" as const,
};
const updated = staffing
? await payload.update({
collection: "npc-staffing",
id: staffing.id,
data,
overrideAccess: true,
})
: await payload.create({
collection: "npc-staffing",
data: { ...data, hiredAt: new Date().toISOString() },
overrideAccess: true,
});
if (headcountDelta !== 0) {
await payload.update({
collection: "labor-classifications",
id: classification.id,
data: { availableHeadcount: availableHeadcount - headcountDelta },
overrideAccess: true,
});
}
await emitGameEvent(payload, {
type: EventTypes.StaffHeadcountSet,
message: `${classificationLabel} crew set to ${input.headcount} at ${structure.name} ($${unitSalary}/worker per tick)`,
structure: structure.id,
targetCollection: "npc-staffing",
targetId: updated.id,
data: {
classification: classification.key,
headcount: input.headcount,
unitSalary,
},
});
return { id: updated.id };
}
export async function fireStaff(
payload: Payload,
staffingId: number,
actorUserId?: number,
): Promise<NpcStaffing> {
const staffing = await payload
.findByID({ collection: "npc-staffing", id: staffingId, depth: 1, overrideAccess: true })
.catch(() => null);
if (!staffing) throw new Error("Staff assignment not found.");
if (staffing.status !== "active") {
throw new Error("This staff assignment is already terminated.");
}
const npcId = relationshipId(staffing.npc);
const structureId = relationshipId(staffing.structure);
const laborClassificationId = relationshipId(staffing.laborClassification);
const npcName =
typeof staffing.npc === "object" && staffing.npc !== null
? staffing.npc.name
: staffing.positionTitle;
const updated = await payload.update({
collection: "npc-staffing",
id: staffing.id,
data: { status: "terminated", terminatedAt: new Date().toISOString() },
overrideAccess: true,
});
if (npcId !== null) {
await payload.update({
collection: "game-npcs",
id: npcId,
data: { location: null, isInGame: false },
overrideAccess: true,
});
}
if (npcId === null && laborClassificationId !== null) {
const classification = await payload.findByID({
collection: "labor-classifications",
id: laborClassificationId,
depth: 0,
overrideAccess: true,
});
await payload.update({
collection: "labor-classifications",
id: classification.id,
data: {
availableHeadcount: (classification.availableHeadcount ?? 0) + (staffing.headcount ?? 1),
},
overrideAccess: true,
});
}
await emitGameEvent(payload, {
type: EventTypes.StaffTerminated,
message: `${npcName}'s assignment as ${staffing.positionTitle} was terminated`,
actor: actorUserId,
structure: structureId ?? undefined,
targetCollection: "npc-staffing",
targetId: staffing.id,
});
return updated;
}

42
src/lib/base/storage.ts Normal file
View file

@ -0,0 +1,42 @@
import type { Payload } from "payload";
import type { GameStructure, Resource } from "@/payload-types";
type StorageEntry = NonNullable<GameStructure["storedResources"]>[number];
export function relationshipId(
value: number | { id: number } | null | undefined,
): number | null {
if (typeof value === "number") return value;
return value?.id ?? null;
}
export function storedAmount(entries: readonly StorageEntry[], resourceId: number): number {
return entries.reduce((total, entry) => {
return total + (relationshipId(entry.resource) === resourceId ? entry.amount : 0);
}, 0);
}
export function consumeStorage(
entries: readonly StorageEntry[],
resourceId: number,
requested: number,
): { readonly entries: StorageEntry[]; readonly remaining: number } {
let remaining = requested;
const updated = entries
.map((entry) => {
if (remaining <= 0 || relationshipId(entry.resource) !== resourceId) return entry;
const consumed = Math.min(entry.amount, remaining);
remaining -= consumed;
return { ...entry, amount: entry.amount - consumed };
})
.filter((entry) => entry.amount > 0);
return { entries: updated, remaining };
}
export async function resourceName(payload: Payload, resource: number | Resource): Promise<string> {
if (typeof resource === "object") return resource.name;
const document = await payload
.findByID({ collection: "resources", id: resource, depth: 0, overrideAccess: true })
.catch(() => null);
return document?.name ?? `Resource #${resource}`;
}

51
src/lib/base/types.ts Normal file
View file

@ -0,0 +1,51 @@
export type HireStaffInput = {
readonly npcId: number;
readonly structureId: number;
readonly positionType: string;
readonly positionTitle: string;
readonly salary: number;
};
export type LaborClassificationOption = {
readonly id: number;
readonly name: string;
readonly key: string;
readonly defaultUnitSalary: number | null;
readonly availableHeadcount: number;
};
export type StaffingPositionType = "administrative" | "technical" | "other";
export type StructureStaffingOption = {
readonly id: number;
readonly name: string;
readonly maintenanceCost: number;
readonly laborRequirements: readonly {
readonly classificationId: number;
readonly requiredHeadcount: number;
readonly maxHeadcount: number;
readonly enforcedSalary: number | null;
readonly currentHeadcount: number;
}[];
readonly staffPositions: readonly {
readonly positionType: StaffingPositionType;
readonly max: number | null;
readonly enforcedSalary: number | null;
readonly currentHeadcount: number;
}[];
};
export type SetLaborHeadcountInput = {
readonly structureId: number;
readonly classificationId: number;
readonly headcount: number;
readonly unitSalary: number;
};
export type BaseTickResult = {
readonly salariesPaid: number;
readonly salariesUnpaid: number;
readonly maintenancePaid: number;
readonly maintenanceUnpaid: number;
readonly upkeepShortages: number;
};

91
src/lib/base/upgrade.ts Normal file
View file

@ -0,0 +1,91 @@
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 { staffingDefaultsFromBlueprint } from "./staffingDefaults";
import { consumeStorage, relationshipId, resourceName, storedAmount } from "./storage";
async function structureBlueprint(payload: Payload, source: GameStructure): Promise<Structure> {
if (typeof source.type === "object") return source.type;
return payload.findByID({
collection: "structures",
id: source.type,
depth: 2,
overrideAccess: true,
});
}
export async function upgradeStructure(
payload: Payload,
gameStructureId: number,
): Promise<GameStructure> {
const gameStructure = await payload.findByID({
collection: "game-structures",
id: gameStructureId,
depth: 2,
overrideAccess: true,
});
const source = await structureBlueprint(payload, gameStructure);
const targetReference = source.upgradesInto;
if (!targetReference) throw new Error("This structure has no upgrade path.");
const target =
typeof targetReference === "object"
? targetReference
: await payload.findByID({
collection: "structures",
id: targetReference,
depth: 2,
overrideAccess: true,
});
const multiplier = source.upgradeCostMultiplier ?? 1;
const requirements = new Map<number, { needed: number; name: string }>();
for (const material of target.materials) {
const resourceId = relationshipId(material.resource);
if (resourceId === null) continue;
const current = requirements.get(resourceId);
requirements.set(resourceId, {
needed: (current?.needed ?? 0) + Math.ceil((material.amount ?? 0) * multiplier),
name: current?.name ?? (await resourceName(payload, material.resource)),
});
}
let storedResources = [...(gameStructure.storedResources ?? [])];
let voidStorage = [...(gameStructure.voidStorage ?? [])];
const shortages: string[] = [];
for (const [resourceId, requirement] of requirements) {
const available =
storedAmount(storedResources, resourceId) + storedAmount(voidStorage, resourceId);
if (available < requirement.needed) {
shortages.push(`${requirement.name} (have ${available}, need ${requirement.needed})`);
}
}
if (shortages.length > 0) throw new Error(`Missing materials: ${shortages.join(", ")}`);
for (const [resourceId, requirement] of requirements) {
const storedResult = consumeStorage(storedResources, resourceId, requirement.needed);
storedResources = storedResult.entries;
voidStorage = consumeStorage(voidStorage, resourceId, storedResult.remaining).entries;
}
const updated = await payload.update({
collection: "game-structures",
id: gameStructure.id,
data: {
type: target.id,
storedResources,
voidStorage,
...staffingDefaultsFromBlueprint(target),
},
overrideAccess: true,
});
await emitGameEvent(payload, {
type: EventTypes.StructureUpgraded,
message: `Structure "${gameStructure.name}" upgraded to ${target.name}`,
structure: gameStructure.id,
targetCollection: "game-structures",
targetId: gameStructure.id,
});
return updated;
}