1
0
Fork 0
polaris-task-force/src/lib/operations/settlementFacts.ts
Z8MB1E 67c993ca10 feat(operations): mission reservations and deterministic settlement
Reserve personnel, vehicles, cargo, and budget with idempotent keys and exactly-once settlement; extraction effects preflight before any write so validated HQ deposits and locker credits are final and survive mission failure. Failure sweeps touch only unextracted reservations.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-20 02:43:47 -04:00

163 lines
5.8 KiB
TypeScript

import type { Payload, PayloadRequest } from "payload";
import type { Asset, OperationEffect } from "@/payload-types";
import { notifyUser } from "@/lib/notifications";
import { emitGameEvent } from "@/utils/event-log/emit";
import { EventTypes } from "@/utils/event-log/eventTypes";
import { failOperationReservations } from "./allocation";
import { findByIdOrNull } from "./settlementResolve";
import { applyZonePressure, PRESSURE_OUTCOME_DELTAS } from "./territory";
import {
parseInventoryLossPayload,
parseObjectivePayload,
parseOperationEndPayload,
parsePlayerLine,
} from "./settlementParse";
import {
MISSION_FAILURE_OUTCOMES,
SettlementFailure,
} from "./settlementTypes";
import type { LockerFitLine } from "./settlementFit";
/**
* AAR-fact handlers: inventory loss evidence, objective completion, and
* operation-end sweeps. These record statistics-only facts (effect row,
* event log, notification) and never award rewards or mutate inventory.
*/
export async function handleInventoryLoss(
payload: Payload,
effect: OperationEffect,
req?: PayloadRequest,
): Promise<string | null> {
const parsed = parseInventoryLossPayload(effect.payload);
if (!parsed.ok) throw new SettlementFailure(parsed.code, parsed.message);
const loss = parsed.value;
// Record-only inventory delta: no loss reason, no evidence to record.
if (!loss.reason) return null;
const lines: LockerFitLine[] = [];
for (const raw of loss.items ?? []) {
const line = parsePlayerLine(raw);
if (!line.ok) throw new SettlementFailure(line.code, line.message);
const asset = (await findByIdOrNull(
payload,
"assets",
line.value.assetId,
req,
)) as unknown as Asset | null;
if (!asset) {
throw new SettlementFailure("unresolved-asset", `Asset #${line.value.assetId} does not exist.`);
}
lines.push({ ...line.value, asset });
}
// Death-loss rule: evidence only. No locker/HQ mutation and no scoreboard
// write (the profile XP hook awards XP for ANY scoreboard key, so a
// scoreboard write would be a reward, which is prohibited).
if (loss.reason === "death" && loss.actorId) {
await emitGameEvent(payload, {
type: EventTypes.PlayerDeath,
message: `${lines.length} item line(s) recorded as unextracted losses`,
actor: loss.actorId,
targetCollection: "users",
targetId: loss.actorId,
data: {
operationId: loss.operationId,
effectKey: effect.effectKey,
items: lines.map((line) => ({ assetId: line.assetId, quantity: line.quantity })),
},
});
}
if (loss.actorId) {
const total = lines.reduce((sum, line) => sum + line.quantity, 0);
await notifyUser(payload, {
userId: loss.actorId,
type: "operation:death",
title: loss.reason === "death" ? "KIA: unextracted inventory lost" : "Inventory lost",
message: `${total.toLocaleString()} item unit(s) recorded as lost (unextracted). No recovery policy applies.`,
link: "/locker",
});
}
return null;
}
function objectiveName(objective: unknown): string | null {
if (typeof objective === "string" && objective) return objective;
if (typeof objective === "object" && objective !== null) {
const record = objective as Record<string, unknown>;
if (typeof record.name === "string" && record.name) return record.name;
if (typeof record.id === "string" && record.id) return record.id;
}
return null;
}
export async function handleObjective(
payload: Payload,
effect: OperationEffect,
): Promise<string | null> {
const parsed = parseObjectivePayload(effect.payload);
if (!parsed.ok) throw new SettlementFailure(parsed.code, parsed.message);
const objective = parsed.value;
// AAR only: objective completion never touches inventory.
if (objective.actorId) {
const name = objectiveName(objective.objective);
await notifyUser(payload, {
userId: objective.actorId,
type: "operation:objective",
title: "Objective updated",
message: name ? `Objective updated: ${name}.` : "An operation objective was updated.",
});
}
return null;
}
export async function handleOperationEnd(
payload: Payload,
effect: OperationEffect,
req?: PayloadRequest,
): Promise<string | null> {
const parsed = parseOperationEndPayload(effect.payload);
if (!parsed.ok) throw new SettlementFailure(parsed.code, parsed.message);
const end = parsed.value;
const failed = MISSION_FAILURE_OUTCOMES.includes(
end.outcome as (typeof MISSION_FAILURE_OUTCOMES)[number],
);
const notes: string[] = [];
if (failed) {
// Mission failure settles only still-reserved rows; it never writes or
// reverses effects, so recorded extraction deposits stay final.
const settled = await failOperationReservations(
payload,
end.operationId,
{ effectId: effect.id, actorId: end.actorId },
req,
);
notes.push(`mission-failed: settled ${settled} reservation(s)`);
}
// Mission-scoped strategic pressure (task 7): an explicit zone on the end
// payload receives one attributed delta; replays are no-ops by sourceEffect.
if (end.zoneId != null) {
const delta = failed ? PRESSURE_OUTCOME_DELTAS.failure : PRESSURE_OUTCOME_DELTAS.success;
const applied = await applyZonePressure(
payload,
{
zoneId: end.zoneId,
factionId: end.factionId,
operationId: end.operationId,
missionId: end.missionId,
sourceEffectId: effect.id,
delta,
note: `operation ${end.operationId} ended (${end.outcome ?? "unknown outcome"}): pressure ${delta > 0 ? "+" : ""}${delta}`,
},
req,
);
notes.push(
applied.created
? `zone-pressure ${delta > 0 ? "+" : ""}${delta} on zone #${end.zoneId}`
: `zone-pressure replay skipped (zone #${end.zoneId})`,
);
}
return notes.length > 0 ? notes.join("; ") : null;
}