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>
163 lines
5.8 KiB
TypeScript
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;
|
|
}
|