diff --git a/src/lib/base/index.ts b/src/lib/base/index.ts new file mode 100644 index 0000000..6f93534 --- /dev/null +++ b/src/lib/base/index.ts @@ -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"; diff --git a/src/lib/base/staffing.ts b/src/lib/base/staffing.ts new file mode 100644 index 0000000..735650c --- /dev/null +++ b/src/lib/base/staffing.ts @@ -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 { + 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 { + 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 { + 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; +} diff --git a/src/lib/base/storage.ts b/src/lib/base/storage.ts new file mode 100644 index 0000000..46d8e19 --- /dev/null +++ b/src/lib/base/storage.ts @@ -0,0 +1,42 @@ +import type { Payload } from "payload"; +import type { GameStructure, Resource } from "@/payload-types"; + +type StorageEntry = NonNullable[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 { + 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}`; +} diff --git a/src/lib/base/types.ts b/src/lib/base/types.ts new file mode 100644 index 0000000..1eab60f --- /dev/null +++ b/src/lib/base/types.ts @@ -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; +}; diff --git a/src/lib/base/upgrade.ts b/src/lib/base/upgrade.ts new file mode 100644 index 0000000..5346300 --- /dev/null +++ b/src/lib/base/upgrade.ts @@ -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 { + 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 { + 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(); + + 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; +}