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:
parent
b2718b3633
commit
c33d0ceb27
5 changed files with 532 additions and 0 deletions
11
src/lib/base/index.ts
Normal file
11
src/lib/base/index.ts
Normal 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
337
src/lib/base/staffing.ts
Normal 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
42
src/lib/base/storage.ts
Normal 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
51
src/lib/base/types.ts
Normal 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
91
src/lib/base/upgrade.ts
Normal 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;
|
||||
}
|
||||
Loading…
Reference in a new issue