From 4e9c53968291ef34a0e8956e9429cf71e77399e2 Mon Sep 17 00:00:00 2001 From: Z8MB1E Date: Sun, 6 Sep 2026 01:24:11 -0400 Subject: [PATCH] feat(base): add base tick bin for salaries, maintenance, and upkeep Registers the base-tick bin: pays staff salaries from the treasury, charges structure maintenance, tracks upkeep shortages from storage, and notifies clients via the game-tick SSE path. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/lib/base/tick.ts | 188 ++++++++++++++++++++++++++++++++++++++++ src/payload.config.ts | 4 + src/scripts/baseTick.ts | 40 +++++++++ 3 files changed, 232 insertions(+) create mode 100644 src/lib/base/tick.ts create mode 100644 src/scripts/baseTick.ts diff --git a/src/lib/base/tick.ts b/src/lib/base/tick.ts new file mode 100644 index 0000000..40a21f7 --- /dev/null +++ b/src/lib/base/tick.ts @@ -0,0 +1,188 @@ +import type { Payload } from "payload"; +import { applyTransaction, getTreasuryAccountId } from "@/lib/banking"; +import { emitGameEvent } from "@/utils/event-log/emit"; +import { EventTypes } from "@/utils/event-log/eventTypes"; +import { consumeStorage, relationshipId, resourceName, storedAmount } from "./storage"; +import type { BaseTickResult } from "./types"; + +async function processSalaries( + payload: Payload, +): Promise> { + const treasuryId = await getTreasuryAccountId(payload); + if (treasuryId === null) { + payload.logger.warn("[Base Tick] No open treasury account; skipping staff salaries."); + return { salariesPaid: 0, salariesUnpaid: 0 }; + } + + const activeStaff = await payload.find({ + collection: "npc-staffing", + where: { and: [{ status: { equals: "active" } }, { salary: { greater_than: 0 } }] }, + limit: 500, + depth: 1, + overrideAccess: true, + }); + let salariesPaid = 0; + let salariesUnpaid = 0; + for (const staffing of activeStaff.docs) { + const unitSalary = staffing.salary ?? 0; + const headcount = staffing.headcount ?? 1; + const salary = unitSalary * headcount; + const isLabor = staffing.positionType === "labor"; + const classification = + typeof staffing.laborClassification === "object" && staffing.laborClassification !== null + ? staffing.laborClassification.name + : null; + const classificationLabel = classification ?? "Unclassified"; + const npcName = + typeof staffing.npc === "object" && staffing.npc !== null + ? staffing.npc.name + : staffing.positionTitle; + const memo = isLabor + ? `Labor: ${headcount} ${classificationLabel}(s) @ ${unitSalary}` + : `Salary: ${staffing.positionTitle} — ${npcName}`; + try { + await applyTransaction(payload, { + type: "salary", + fromAccountId: treasuryId, + amount: salary, + memo, + }); + await emitGameEvent(payload, { + type: EventTypes.SalaryPaid, + message: `Paid ${salary} salary to ${npcName}`, + structure: relationshipId(staffing.structure) ?? undefined, + targetCollection: "npc-staffing", + targetId: staffing.id, + data: { amount: salary, headcount, unitSalary }, + }); + salariesPaid += 1; + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + await emitGameEvent(payload, { + type: EventTypes.SalaryUnpaid, + message: `Salary ${salary} unpaid for ${npcName}: ${message}`, + structure: relationshipId(staffing.structure) ?? undefined, + targetCollection: "npc-staffing", + targetId: staffing.id, + data: { amount: salary, headcount, unitSalary, error: message }, + }); + salariesUnpaid += 1; + } + } + return { salariesPaid, salariesUnpaid }; +} + +async function processUpkeep(payload: Payload): Promise { + const structures = await payload.find({ + collection: "game-structures", + limit: 500, + depth: 1, + overrideAccess: true, + }); + let upkeepShortages = 0; + + for (const structure of structures.docs) { + if (typeof structure.type !== "object" || !structure.type.upkeep?.length) continue; + let storedResources = [...(structure.storedResources ?? [])]; + let voidStorage = [...(structure.voidStorage ?? [])]; + let consumedAny = false; + + for (const upkeep of structure.type.upkeep) { + const resourceId = relationshipId(upkeep.resource); + const needed = upkeep.amount ?? 0; + if (resourceId === null || needed <= 0) continue; + const available = + storedAmount(storedResources, resourceId) + storedAmount(voidStorage, resourceId); + if (available < needed) { + const name = await resourceName(payload, upkeep.resource ?? resourceId); + await emitGameEvent(payload, { + type: EventTypes.UpkeepShort, + message: `Structure "${structure.name}" short on upkeep: ${name} (have ${available}, need ${needed})`, + structure: structure.id, + targetCollection: "game-structures", + targetId: structure.id, + }); + upkeepShortages += 1; + } + + const amountToConsume = Math.min(available, needed); + const storedResult = consumeStorage(storedResources, resourceId, amountToConsume); + storedResources = storedResult.entries; + voidStorage = consumeStorage(voidStorage, resourceId, storedResult.remaining).entries; + consumedAny ||= amountToConsume > 0; + } + + if (consumedAny) { + await payload.update({ + collection: "game-structures", + id: structure.id, + data: { storedResources, voidStorage }, + context: { skipGridRepack: true }, + overrideAccess: true, + }); + } + } + return upkeepShortages; +} + +async function processMaintenance( + payload: Payload, +): Promise> { + const treasuryId = await getTreasuryAccountId(payload); + if (treasuryId === null) { + payload.logger.warn("[Base Tick] No open treasury account; skipping structure maintenance."); + return { maintenancePaid: 0, maintenanceUnpaid: 0 }; + } + + const structures = await payload.find({ + collection: "game-structures", + where: { maintenanceCost: { greater_than: 0 } }, + limit: 500, + depth: 0, + overrideAccess: true, + }); + let maintenancePaid = 0; + let maintenanceUnpaid = 0; + + for (const structure of structures.docs) { + const amount = structure.maintenanceCost ?? 0; + if (amount <= 0) continue; + try { + await applyTransaction(payload, { + type: "fee", + fromAccountId: treasuryId, + amount, + memo: `Structure maintenance: ${structure.name}`, + }); + await emitGameEvent(payload, { + type: EventTypes.MaintenancePaid, + message: `Paid ${amount} maintenance for ${structure.name}`, + structure: structure.id, + targetCollection: "game-structures", + targetId: structure.id, + data: { amount }, + }); + maintenancePaid += 1; + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + await emitGameEvent(payload, { + type: EventTypes.MaintenanceUnpaid, + message: `Maintenance ${amount} unpaid for ${structure.name}: ${message}`, + structure: structure.id, + targetCollection: "game-structures", + targetId: structure.id, + data: { amount, error: message }, + }); + maintenanceUnpaid += 1; + } + } + + return { maintenancePaid, maintenanceUnpaid }; +} + +export async function processBaseTick(payload: Payload): Promise { + const salaries = await processSalaries(payload); + const maintenance = await processMaintenance(payload); + const upkeepShortages = await processUpkeep(payload); + return { ...salaries, ...maintenance, upkeepShortages }; +} diff --git a/src/payload.config.ts b/src/payload.config.ts index cb71563..a95f35b 100644 --- a/src/payload.config.ts +++ b/src/payload.config.ts @@ -318,6 +318,10 @@ export default buildConfig({ scriptPath: path.resolve(dirname, "scripts/serverTick.ts"), key: "server-tick", }, + { + scriptPath: path.resolve(dirname, "scripts/baseTick.ts"), + key: "base-tick", + }, { scriptPath: path.resolve(dirname, "scripts/generateNextMission.ts"), key: "generate-mission", diff --git a/src/scripts/baseTick.ts b/src/scripts/baseTick.ts new file mode 100644 index 0000000..00f87d6 --- /dev/null +++ b/src/scripts/baseTick.ts @@ -0,0 +1,40 @@ +import type { SanitizedConfig } from "payload"; +import { getPayload } from "payload"; +import { processBaseTick } from "@/lib/base"; +import { createBinLogger, errorMessage } from "./lib/binFileLogger"; + +export const script = async (config: SanitizedConfig) => { + const payload = await getPayload({ config }); + const log = createBinLogger("base-tick", payload.logger); + + try { + log.info("[Base Tick] Starting base tick..."); + const processedAt = new Date().toISOString(); + const result = await processBaseTick(payload); + log.info( + `[Base Tick] Complete. Salaries paid ${result.salariesPaid}, unpaid ${result.salariesUnpaid}; maintenance paid ${result.maintenancePaid}, unpaid ${result.maintenanceUnpaid}; upkeep shortages ${result.upkeepShortages}.`, + ); + + const notifyUrl = (process.env.APP_URL ?? "http://localhost:3000").replace(/\/$/, ""); + const notifySecret = process.env.GAME_TICK_NOTIFY_SECRET; + if (notifySecret) { + await fetch(`${notifyUrl}/api/game-tick/notify`, { + method: "POST", + headers: { + "content-type": "application/json", + "x-game-tick-secret": notifySecret, + }, + body: JSON.stringify({ processedAt, source: "base-tick" }), + }).catch((error: unknown) => { + log.error(`[Base Tick] Failed to notify app clients: ${errorMessage(error)}`); + }); + } + + process.exit(0); + } catch (error) { + log.error( + `[Base Tick] Fatal: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}`, + ); + process.exit(1); + } +};