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:
parent
c33d0ceb27
commit
4e9c539682
3 changed files with 232 additions and 0 deletions
188
src/lib/base/tick.ts
Normal file
188
src/lib/base/tick.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -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
40
src/scripts/baseTick.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
Loading…
Reference in a new issue