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