1
0
Fork 0

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 <clio-agent@sisyphuslabs.ai>
This commit is contained in:
Jason Fraley 2026-09-06 01:24:11 -04:00
parent c33d0ceb27
commit 4e9c539682
3 changed files with 232 additions and 0 deletions

188
src/lib/base/tick.ts Normal file
View file

@ -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<Pick<BaseTickResult, "salariesPaid" | "salariesUnpaid">> {
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<number> {
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<Pick<BaseTickResult, "maintenancePaid" | "maintenanceUnpaid">> {
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<BaseTickResult> {
const salaries = await processSalaries(payload);
const maintenance = await processMaintenance(payload);
const upkeepShortages = await processUpkeep(payload);
return { ...salaries, ...maintenance, upkeepShortages };
}

View file

@ -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",

40
src/scripts/baseTick.ts Normal file
View file

@ -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);
}
};