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 { 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; 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 { 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 { 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; }