1
0
Fork 0

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>
This commit is contained in:
Jason Fraley 2026-09-20 02:43:47 -04:00
parent cc23742dd8
commit 67c993ca10
17 changed files with 34621 additions and 0 deletions

View file

@ -0,0 +1,146 @@
import type { CollectionConfig } from "payload";
/**
* Mission allocation reservations.
*
* One row per allocation request against an operation: personnel, vehicles,
* cargo/resources, and a treasury budget earmarked for a mission, plus the
* origin/destination structures and an optional expiry. Reservations are
* earmarks only — no locker, shipment, bank, or storage rows are mutated at
* reserve or settle time; the deterministic settlement record on the row
* captures what was consumed versus returned, exactly once.
*
* Settlement state machine: `reserved` -> `settled` | `cancelled` | `expired`
* | `no-show` (all terminal). `reservationKey` is the idempotency key: a
* retry with the same key returns the existing row instead of creating a
* second reservation.
*
* Writes are internal only (create/update/delete denied; the allocation
* service in `src/lib/operations/allocation.ts` writes via `overrideAccess`).
* Read is open to any logged-in user.
*/
export const OperationReservations: CollectionConfig = {
slug: "operation-reservations",
admin: {
group: "Operations",
useAsTitle: "reservationKey",
defaultColumns: ["reservationKey", "operationId", "status", "expiresAt", "settledAt"],
},
access: {
read: ({ req }) => !!req.user,
create: () => false,
update: () => false,
delete: () => false,
},
fields: [
{
name: "reservationKey",
type: "text",
required: true,
unique: true,
index: true,
admin: {
description: "Idempotency key for the allocation request. Retries with the same key are no-ops.",
},
},
{
name: "operationId",
type: "text",
required: true,
index: true,
admin: { description: "Operation identity this allocation belongs to." },
},
{
name: "mission",
type: "relationship",
relationTo: "missions",
admin: { description: "The mission these assets are allocated to, when known." },
},
{
name: "status",
type: "select",
required: true,
defaultValue: "reserved",
options: [
{ label: "Reserved", value: "reserved" },
{ label: "Settled", value: "settled" },
{ label: "Cancelled", value: "cancelled" },
{ label: "Expired", value: "expired" },
{ label: "No-Show", value: "no-show" },
],
admin: {
description: "reserved is the only non-terminal state; every terminal state is settled exactly once.",
},
},
{
name: "personnel",
type: "array",
admin: { description: "Reserved unit members." },
fields: [
{ name: "user", type: "relationship", relationTo: "users", required: true },
{ name: "slot", type: "text", admin: { description: "Optional role/slot label." } },
],
},
{
name: "vehicles",
type: "array",
admin: { description: "Reserved deployed vehicles." },
fields: [
{ name: "vehicle", type: "relationship", relationTo: "game-vehicles", required: true },
],
},
{
name: "cargo",
type: "array",
admin: { description: "Reserved resources (drawn from the origin structure). " },
fields: [
{ name: "resource", type: "relationship", relationTo: "resources", required: true },
{ name: "amount", type: "number", required: true, min: 1 },
],
},
{
name: "budget",
type: "group",
admin: { description: "Treasury budget earmarked for the operation." },
fields: [
{ name: "account", type: "relationship", relationTo: "bank-accounts" },
{ name: "amount", type: "number", min: 0 },
],
},
{
name: "origin",
type: "relationship",
relationTo: "game-structures",
admin: { description: "Structure the reserved cargo/vehicles are drawn from." },
},
{
name: "destination",
type: "relationship",
relationTo: "game-structures",
admin: { description: "Structure the cargo is bound for (storage rules checked at reserve time)." },
},
{
name: "expiresAt",
type: "date",
admin: { description: "When the reservation lapses; the expiry sweeper settles it as expired." },
},
{ name: "createdBy", type: "relationship", relationTo: "users" },
{ name: "settledAt", type: "date" },
{
name: "settledByEffect",
type: "relationship",
relationTo: "operation-effects",
admin: {
description: "Provenance: the operation effect that drove this settlement, when effect-driven.",
},
},
{
name: "settlement",
type: "json",
admin: {
description:
"Deterministic settlement record: outcome, reason, consumed and returned amounts, actor, effect, timestamp.",
},
},
],
};

View file

@ -0,0 +1,211 @@
import type { Payload, PayloadRequest } from "payload";
import type { OperationReservation } from "@/payload-types";
import {
type ReservationRow,
type ReserveOperationAssetsInput,
type SettlementConsumed,
type SettlementOutcome,
type SettlementRecord,
} from "./allocationTypes";
import { validateReservation } from "./allocationValidate";
import {
computeReturned,
defaultReason,
normalizeConsumed,
reservedAmounts,
} from "./settlementMath";
/**
* Mission allocation reservations: reserve assets against an operation, then
* settle each reservation exactly once.
*
* Reservations are earmarks with capacity checks. No locker, shipment, bank,
* or storage rows are mutated here: reserving validates that the requested
* personnel, vehicles, cargo, and treasury budget are available (net of other
* active reservations), and settlement records the consumed versus returned
* amounts on the reservation row itself. Actual asset movement from these
* settlement records is extraction/economy scope (later roadmap tasks).
*
* Non-negotiable policy: a later mission failure must not reverse any
* separately validated extraction deposit. `failOperationReservations` only
* settles rows still in `reserved` state and never writes to the
* operation-effects ledger, so recorded extraction effects stay final.
*/
export type {
ReservationCargoLine,
ReservationRow,
ReserveOperationAssetsInput,
ReservePersonnelLine,
SettlementAmounts,
SettlementConsumed,
SettlementOutcome,
SettlementRecord,
} from "./allocationTypes";
export { expireOperationReservations, failOperationReservations } from "./allocationSweep";
function pgErrorCode(error: unknown): string | undefined {
const cause = (error as { cause?: { code?: string } })?.cause;
return cause?.code ?? (error as { code?: string })?.code;
}
async function findByKey(
payload: Payload,
key: string,
req?: PayloadRequest,
): Promise<OperationReservation | null> {
const res = await payload.find({
collection: "operation-reservations",
where: { reservationKey: { equals: key } },
limit: 1,
depth: 0,
overrideAccess: true,
req,
});
return (res.docs[0] as OperationReservation | undefined) ?? null;
}
async function activeReservationRows(
payload: Payload,
req?: PayloadRequest,
): Promise<ReservationRow[]> {
const res = await payload.find({
collection: "operation-reservations",
where: { status: { equals: "reserved" } },
limit: 1000,
depth: 0,
overrideAccess: true,
req,
});
return res.docs as unknown as ReservationRow[];
}
/**
* Reserve assets for an operation. Idempotent by `reservationKey`: a retry
* with an existing key returns the stored row. Every capacity check runs
* before the create, so an insufficient allocation fails without partial
* writes.
*/
export async function reserveOperationAssets(
payload: Payload,
input: ReserveOperationAssetsInput,
req?: PayloadRequest,
): Promise<OperationReservation> {
if (input.reservationKey) {
const existing = await findByKey(payload, input.reservationKey, req);
if (existing) return existing;
}
const active = await activeReservationRows(payload, req);
await validateReservation(payload, input, active, req);
try {
return (await payload.create({
collection: "operation-reservations",
data: {
reservationKey: input.reservationKey,
operationId: input.operationId,
mission: input.missionId ?? null,
status: "reserved",
personnel: (input.personnel ?? []).map((p) => ({ user: p.userId, slot: p.slot ?? null })),
vehicles: (input.vehicleIds ?? []).map((v) => ({ vehicle: v })),
cargo: (input.cargo ?? []).map((c) => ({ resource: c.resourceId, amount: c.amount })),
...(input.budget
? { budget: { account: input.budget.accountId, amount: input.budget.amount } }
: {}),
origin: input.originId ?? null,
destination: input.destinationId ?? null,
expiresAt: input.expiresAt ?? null,
createdBy: input.actorId,
},
overrideAccess: true,
depth: 0,
req,
})) as unknown as OperationReservation;
} catch (error) {
if (pgErrorCode(error) === "23505") {
// Unique-constraint race on reservationKey: the other request won.
const existing = await findByKey(payload, input.reservationKey, req);
if (existing) return existing;
}
throw error;
}
}
/**
* Settle a reservation exactly once. A reservation already in a terminal
* state is returned unchanged (idempotent no-op); the stored status is
* re-read immediately before the outcome is computed, mirroring the
* completeShipment guard.
*/
export async function settleOperationReservation(
payload: Payload,
ref: { reservationId?: number; reservationKey?: string },
opts: {
outcome: SettlementOutcome;
consumed?: SettlementConsumed;
reason?: string;
effectId?: number;
actorId?: number;
},
req?: PayloadRequest,
): Promise<OperationReservation> {
let located: OperationReservation | null = null;
if (ref.reservationId != null) {
located = (await payload
.findByID({
collection: "operation-reservations",
id: ref.reservationId,
depth: 0,
overrideAccess: true,
req,
})
.catch(() => null)) as unknown as OperationReservation | null;
} else if (ref.reservationKey) {
located = await findByKey(payload, ref.reservationKey, req);
}
if (!located) throw new Error("Reservation not found.");
// Fresh re-read: another caller may have settled since `located` was read.
const fresh = (await payload
.findByID({
collection: "operation-reservations",
id: located.id,
depth: 0,
overrideAccess: true,
req,
})
.catch(() => null)) as unknown as OperationReservation | null;
if (!fresh) throw new Error("Reservation not found.");
if (fresh.status !== "reserved") return fresh;
const row = fresh as unknown as ReservationRow;
const reserved = reservedAmounts(row);
const consumed = normalizeConsumed(reserved, opts.outcome, opts.consumed);
const returned = computeReturned(reserved, consumed);
const settlement: SettlementRecord = {
outcome: opts.outcome,
reason: opts.reason ?? defaultReason(opts.outcome),
consumed,
returned,
actorId: opts.actorId ?? null,
effectId: opts.effectId ?? null,
settledAt: new Date().toISOString(),
};
return (await payload.update({
collection: "operation-reservations",
id: fresh.id,
data: {
status: opts.outcome,
settledAt: settlement.settledAt,
settledByEffect: opts.effectId ?? null,
settlement,
},
overrideAccess: true,
depth: 0,
req,
})) as unknown as OperationReservation;
}

View file

@ -0,0 +1,92 @@
import type { Payload, PayloadRequest } from "payload";
import type { ReservationRow, SettlementConsumed } from "./allocationTypes";
import { settleOperationReservation } from "./allocation";
/**
* Operation-wide batch settlement: mission failure and expiry sweeps. Each
* row still settles through `settleOperationReservation`, so the exactly-once
* guard applies per row and re-running a sweep is a no-op.
*/
/**
* Mission-failure settlement: every still-reserved reservation for the
* operation settles with reason `mission-failed`, consuming only what the
* caller evidences and returning the rest. Rows already settled (including
* extraction-driven settlements) are terminal and therefore untouched, and
* no operation-effects rows are written or reversed here: a recorded
* extraction deposit stays final even when the mission fails.
*/
export async function failOperationReservations(
payload: Payload,
operationId: string,
opts?: {
effectId?: number;
actorId?: number;
consumedByKey?: Record<string, SettlementConsumed>;
},
req?: PayloadRequest,
): Promise<number> {
const active = await payload.find({
collection: "operation-reservations",
where: {
and: [{ operationId: { equals: operationId } }, { status: { equals: "reserved" } }],
},
limit: 1000,
depth: 0,
overrideAccess: true,
req,
});
let settled = 0;
for (const doc of active.docs) {
const row = doc as unknown as ReservationRow;
await settleOperationReservation(
payload,
{ reservationId: row.id },
{
outcome: "settled",
reason: "mission-failed",
consumed: opts?.consumedByKey?.[row.reservationKey],
effectId: opts?.effectId,
actorId: opts?.actorId,
},
req,
);
settled += 1;
}
return settled;
}
/**
* Expiry sweeper: reservations past `expiresAt` while still reserved settle
* as `expired` with everything returned. Idempotent via the settle guard.
*/
export async function expireOperationReservations(
payload: Payload,
now: Date = new Date(),
req?: PayloadRequest,
): Promise<number> {
const due = await payload.find({
collection: "operation-reservations",
where: {
and: [{ status: { equals: "reserved" } }, { expiresAt: { less_than: now.toISOString() } }],
},
limit: 1000,
depth: 0,
overrideAccess: true,
req,
});
let settled = 0;
for (const doc of due.docs) {
const row = doc as unknown as ReservationRow;
await settleOperationReservation(
payload,
{ reservationId: row.id },
{ outcome: "expired" },
req,
);
settled += 1;
}
return settled;
}

View file

@ -0,0 +1,85 @@
/**
* Allocation contracts shared by the reservation service, its validator, and
* the settlement math. Pure module: no Payload imports, safe anywhere.
*/
export interface ReservationCargoLine {
resourceId: number;
amount: number;
}
export interface ReservePersonnelLine {
userId: number;
slot?: string;
}
export interface ReserveOperationAssetsInput {
reservationKey: string;
operationId: string;
actorId: number;
missionId?: number;
personnel?: ReservePersonnelLine[];
vehicleIds?: number[];
cargo?: ReservationCargoLine[];
budget?: { accountId: number; amount: number };
originId?: number;
destinationId?: number;
expiresAt?: string;
}
export type SettlementOutcome = "settled" | "cancelled" | "expired" | "no-show";
export interface SettlementConsumed {
personnel?: number[];
vehicleIds?: number[];
cargo?: ReservationCargoLine[];
budgetAmount?: number;
}
export interface SettlementAmounts {
personnel: number[];
vehicles: number[];
cargo: { resource: number; amount: number }[];
budget: number;
}
export type SettlementRecord = {
outcome: SettlementOutcome;
reason: string;
consumed: SettlementAmounts;
returned: SettlementAmounts;
actorId: number | null;
effectId: number | null;
settledAt: string;
};
/** Depth-0 reservation row shape (relationship fields are plain ids). */
export interface ReservationRow {
id: number;
reservationKey: string;
operationId: string;
status: string;
mission?: number | null;
personnel?: { user: number; slot?: string | null }[] | null;
vehicles?: { vehicle: number }[] | null;
cargo?: { resource: number; amount: number }[] | null;
budget?: { account?: number | null; amount?: number | null } | null;
origin?: number | null;
destination?: number | null;
}
export function relId(value: unknown): number | null {
if (typeof value === "number") return value;
if (value && typeof value === "object" && "id" in value) {
return (value as { id: number }).id;
}
return null;
}
export function aggregateCargo(lines: readonly ReservationCargoLine[]): Map<number, number> {
const totals = new Map<number, number>();
for (const line of lines) {
totals.set(line.resourceId, (totals.get(line.resourceId) ?? 0) + line.amount);
}
return totals;
}

View file

@ -0,0 +1,232 @@
import type { Payload, PayloadRequest } from "payload";
import type { GameStructure } from "@/payload-types";
import { hasPermission } from "@/utils/access-control/hasPermission";
import { storedAmount } from "@/lib/base/storage";
import {
checkStorageDeposit,
storageViolationMessage,
type StorageRulesSource,
} from "@/lib/storageRules";
import {
aggregateCargo,
relId,
type ReservationRow,
type ReserveOperationAssetsInput,
} from "./allocationTypes";
type StorageEntry = NonNullable<GameStructure["storedResources"]>[number];
/**
* Capacity validation for a reservation request. Every check runs before any
* create, so an insufficient allocation fails without partial writes. All
* availability is computed net of the other currently active (`reserved`)
* reservations, which is what prevents double-spending the same stock,
* vehicle, budget, or person.
*/
export async function validateReservation(
payload: Payload,
input: ReserveOperationAssetsInput,
active: ReservationRow[],
req?: PayloadRequest,
): Promise<void> {
const errors: string[] = [];
if (!input.reservationKey) errors.push("A reservation key is required.");
if (!input.operationId) errors.push("An operation id is required.");
const actor = await payload
.findByID({ collection: "users", id: input.actorId, depth: 0, overrideAccess: true, req })
.catch(() => null);
if (!actor) {
errors.push(`Actor #${input.actorId} does not exist.`);
} else if (!(await hasPermission(payload, actor, "operation-reservations:create"))) {
errors.push(`Actor #${input.actorId} is not authorized to allocate operation assets.`);
}
if (input.missionId != null) {
const mission = await payload
.findByID({ collection: "missions", id: input.missionId, depth: 0, overrideAccess: true, req })
.catch(() => null);
if (!mission) errors.push(`Mission #${input.missionId} does not exist.`);
}
const cargoTotals = aggregateCargo(input.cargo ?? []);
for (const [resourceId, amount] of cargoTotals) {
if (!Number.isInteger(amount) || amount <= 0) {
errors.push(`Cargo amount for resource #${resourceId} must be a positive integer.`);
}
}
if (cargoTotals.size > 0 && input.originId == null) {
errors.push("An origin structure is required when cargo is reserved.");
}
if (input.expiresAt != null && Number.isNaN(Date.parse(input.expiresAt))) {
errors.push("expiresAt must be a valid date.");
}
let origin: GameStructure | null = null;
if (input.originId != null) {
origin = (await payload
.findByID({
collection: "game-structures",
id: input.originId,
depth: 0,
overrideAccess: true,
req,
})
.catch(() => null)) as unknown as GameStructure | null;
if (!origin) errors.push(`Origin structure #${input.originId} does not exist.`);
}
const bookedOnMission = new Set<number>();
if (input.missionId != null) {
for (const row of active) {
if (relId(row.mission) !== input.missionId) continue;
for (const p of row.personnel ?? []) bookedOnMission.add(relId(p.user) ?? -1);
}
}
for (const line of input.personnel ?? []) {
if (bookedOnMission.has(line.userId)) {
errors.push(`User #${line.userId} is already reserved on this mission.`);
continue;
}
const user = await payload
.findByID({ collection: "users", id: line.userId, depth: 0, overrideAccess: true, req })
.catch(() => null);
if (!user) errors.push(`User #${line.userId} does not exist.`);
}
const reservedVehicleIds = new Set<number>();
for (const row of active) {
for (const v of row.vehicles ?? []) reservedVehicleIds.add(relId(v.vehicle) ?? -1);
}
for (const vehicleId of input.vehicleIds ?? []) {
if (reservedVehicleIds.has(vehicleId)) {
errors.push(`Vehicle #${vehicleId} is already reserved.`);
continue;
}
const vehicle = (await payload
.findByID({ collection: "game-vehicles", id: vehicleId, depth: 0, overrideAccess: true, req })
.catch(() => null)) as unknown as {
status?: string;
deployedAt?: number | null;
} | null;
if (!vehicle) {
errors.push(`Vehicle #${vehicleId} does not exist.`);
continue;
}
if (vehicle.status !== "idle") {
errors.push(`Vehicle #${vehicleId} is not available (status: ${vehicle.status}).`);
}
if (input.originId != null && relId(vehicle.deployedAt) !== input.originId) {
errors.push(`Vehicle #${vehicleId} is not stationed at the origin structure.`);
}
}
if (origin && cargoTotals.size > 0) {
const originStock: StorageEntry[] = [
...(origin.storedResources ?? []),
...(origin.voidStorage ?? []),
];
const reservedAtOrigin = new Map<number, number>();
for (const row of active) {
if (relId(row.origin) !== input.originId) continue;
for (const c of row.cargo ?? []) {
const rid = relId(c.resource);
if (rid == null) continue;
reservedAtOrigin.set(rid, (reservedAtOrigin.get(rid) ?? 0) + c.amount);
}
}
for (const [resourceId, amount] of cargoTotals) {
const available =
storedAmount(originStock, resourceId) - (reservedAtOrigin.get(resourceId) ?? 0);
if (available < amount) {
errors.push(
`Insufficient cargo: resource #${resourceId} has ${available} available at the origin, ${amount} requested.`,
);
}
}
}
if (input.destinationId != null) {
const destination = (await payload
.findByID({
collection: "game-structures",
id: input.destinationId,
depth: 1,
overrideAccess: true,
req,
})
.catch(() => null)) as unknown as (GameStructure & {
type?: StorageRulesSource | number | null;
}) | null;
if (!destination) {
errors.push(`Destination structure #${input.destinationId} does not exist.`);
} else if (cargoTotals.size > 0 && destination.type && typeof destination.type === "object") {
const rules = destination.type;
const destStock: StorageEntry[] = [
...(destination.storedResources ?? []),
...(destination.voidStorage ?? []),
];
const inbound = new Map<number, number>();
for (const row of active) {
if (relId(row.destination) !== input.destinationId) continue;
for (const c of row.cargo ?? []) {
const rid = relId(c.resource);
if (rid == null) continue;
inbound.set(rid, (inbound.get(rid) ?? 0) + c.amount);
}
}
for (const [resourceId, amount] of cargoTotals) {
const currentStored = storedAmount(destStock, resourceId) + (inbound.get(resourceId) ?? 0);
const check = checkStorageDeposit(rules, resourceId, amount, currentStored);
if (!check.allowed) {
errors.push(storageViolationMessage(`Resource #${resourceId}`, check));
}
}
}
}
if (input.budget) {
if (!Number.isInteger(input.budget.amount) || input.budget.amount <= 0) {
errors.push("Budget amount must be a positive integer.");
} else {
const account = (await payload
.findByID({
collection: "bank-accounts",
id: input.budget.accountId,
depth: 0,
overrideAccess: true,
req,
})
.catch(() => null)) as unknown as {
id: number;
name?: string;
status?: string;
balance?: number | null;
} | null;
if (!account) {
errors.push(`Budget account #${input.budget.accountId} does not exist.`);
} else {
if (account.status !== "open") {
errors.push(`Budget account "${account.name}" is ${account.status}.`);
}
let earmarked = 0;
for (const row of active) {
if (relId(row.budget?.account) === account.id) {
earmarked += row.budget?.amount ?? 0;
}
}
const available = (account.balance ?? 0) - earmarked;
if (available < input.budget.amount) {
errors.push(
`Insufficient budget in "${account.name}": ${available} available, ${input.budget.amount} requested.`,
);
}
}
}
}
if (errors.length > 0) {
throw new Error(`Reservation rejected: ${errors.join(" ")}`);
}
}

View file

@ -0,0 +1,95 @@
import type { Payload, PayloadRequest } from "payload";
import type { OperationEffect } from "@/payload-types";
import { handleExtraction } from "./settlementExtraction";
import {
handleInventoryLoss,
handleObjective,
handleOperationEnd,
} from "./settlementFacts";
import { findByIdOrNull } from "./settlementResolve";
import { SettlementFailure } from "./settlementTypes";
/**
* Operation settlement: applies validated operation effects to real state.
*
* Extraction finality: a validated logistics extraction deposits its
* manifest at the designated HQ structure and a validated player extraction
* credits its manifest to the extractor's personal locker, immediately and
* finally. A later mission failure settles only still-reserved reservations
* (via `failOperationReservations`) and never reverses an applied effect.
*
* Exactly-once: the dispatcher re-reads the effect fresh and no-ops on
* terminal (`applied`/`reversed`) rows, so a replay applies nothing twice.
* Recoverable failures (full HQ, full locker, unresolved references) are
* recorded as `failed` effects with a structured note; the full manifest
* stays on the effect payload, so reconciliation can replay them later.
*/
/**
* Apply one operation effect. Never throws: recoverable failures are
* recorded on the effect row, and unexpected errors are marked
* `internal-error` so the ledger write path stays unbreakable.
*/
export async function applyOperationEffect(
payload: Payload,
effectId: number,
req?: PayloadRequest,
): Promise<OperationEffect | null> {
const effect = (await findByIdOrNull(
payload,
"operation-effects",
effectId,
req,
)) as unknown as OperationEffect | null;
if (!effect) return null;
if (effect.status === "applied" || effect.status === "reversed") return effect;
try {
const note = await dispatchEffect(payload, effect, req);
return (await payload.update({
collection: "operation-effects",
id: effect.id,
data: {
status: "applied",
appliedAt: new Date().toISOString(),
reconciliationNotes: note ?? null,
},
overrideAccess: true,
depth: 0,
req,
})) as unknown as OperationEffect;
} catch (error) {
const code = error instanceof SettlementFailure ? error.code : "internal-error";
const message = error instanceof Error ? error.message : "unknown error";
payload.logger.error(`[Operations] Effect ${effect.effectKey} failed (${code}): ${message}`);
return (await payload.update({
collection: "operation-effects",
id: effect.id,
data: { status: "failed", reconciliationNotes: `${code}: ${message}` },
overrideAccess: true,
depth: 0,
req,
})) as unknown as OperationEffect;
}
}
async function dispatchEffect(
payload: Payload,
effect: OperationEffect,
req?: PayloadRequest,
): Promise<string | null> {
switch (effect.effectType) {
case "operation.started":
return null;
case "operation.objective-updated":
return handleObjective(payload, effect);
case "operation.extraction-recorded":
return handleExtraction(payload, effect, req);
case "operation.inventory-recorded":
return handleInventoryLoss(payload, effect, req);
case "operation.ended":
return handleOperationEnd(payload, effect, req);
default:
throw new SettlementFailure("internal-error", `Unknown effect type "${effect.effectType}".`);
}
}

View file

@ -0,0 +1,258 @@
import type { Payload, PayloadRequest } from "payload";
import type {
Asset,
GameStructure,
LockerStorage,
OperationEffect,
Resource,
Structure,
User,
} from "@/payload-types";
import { addResourceInternal } from "@/app/(frontend)/logistics/structures/actions";
import { creditLockerQuantity } from "@/lib/market";
import { ensureLockerStorage, getLockerGridDimensions } from "@/lib/locker";
import { notifyUser } from "@/lib/notifications";
import { emitGameEvent } from "@/utils/event-log/emit";
import { EventTypes } from "@/utils/event-log/eventTypes";
import { settleOperationReservation } from "./allocation";
import { findByIdOrNull } from "./settlementResolve";
import { preflightHqDeposit, preflightLockerCredit } from "./settlementFit";
import type { LockerFitLine } from "./settlementFit";
import {
parseExtractionPayload,
parseLogisticsLine,
parsePlayerLine,
} from "./settlementParse";
import { SettlementFailure } from "./settlementTypes";
import type { ExtractionPayloadV1, LogisticsExtractionLine } from "./settlementTypes";
/**
* Extraction settlement: converts a validated extraction effect into one
* provenance-linked HQ deposit (logistics) or personal-locker credit
* (player). Both paths preflight the whole manifest before any write, so a
* rejected effect never leaves a partial deposit or credit behind.
*/
type AuditUser = Parameters<typeof addResourceInternal>[1];
async function resolveAuditUser(
payload: Payload,
actorId: number | undefined,
req?: PayloadRequest,
): Promise<AuditUser> {
if (actorId !== undefined) {
const user = await findByIdOrNull(payload, "users", actorId, req);
if (user) return user as unknown as AuditUser;
}
// Evidence stays authoritative without an actor: the deposit still
// applies; only the game-event audit trail loses actor attribution.
return { id: null } as unknown as AuditUser;
}
export async function handleExtraction(
payload: Payload,
effect: OperationEffect,
req?: PayloadRequest,
): Promise<string | null> {
const parsed = parseExtractionPayload(effect.payload);
if (!parsed.ok) throw new SettlementFailure(parsed.code, parsed.message);
const ext = parsed.value;
if (ext.recoveryPolicy && ext.recoveryPolicy.type !== "none") {
throw new SettlementFailure(
"unsupported-recovery-policy",
`Recovery policy "${ext.recoveryPolicy.type}" is not implemented in contract v1.`,
);
}
// Record-only extraction (legacy `{ player }` shape): AAR evidence, no
// inventory movement.
if (!ext.kind) return null;
const rawLines = ext.items ?? [];
if (ext.kind === "logistics") {
await settleLogisticsExtraction(payload, ext, rawLines, req);
} else {
await settlePlayerExtraction(payload, effect, ext, rawLines, req);
}
if (ext.reservationKey) {
return await settleLinkedReservation(payload, ext, effect.id, req);
}
return null;
}
async function settleLogisticsExtraction(
payload: Payload,
ext: ExtractionPayloadV1,
rawLines: unknown[],
req?: PayloadRequest,
): Promise<void> {
const hqId = ext.hqStructureId as number;
const hqResult = await payload.find({
collection: "game-structures",
where: { id: { equals: hqId } },
limit: 1,
depth: 2,
overrideAccess: true,
req,
});
const hq =
(hqResult.docs[0] as unknown as (GameStructure & { type: Structure }) | undefined) ?? null;
if (!hq || typeof hq.type !== "object") {
throw new SettlementFailure("hq-not-found", `HQ structure #${hqId} does not exist.`);
}
const lines: LogisticsExtractionLine[] = [];
const massPerUnit = new Map<number, number>();
for (const raw of rawLines) {
const line = parseLogisticsLine(raw);
if (!line.ok) throw new SettlementFailure(line.code, line.message);
const resource = (await findByIdOrNull(
payload,
"resources",
line.value.resourceId,
req,
)) as unknown as Resource | null;
if (!resource) {
throw new SettlementFailure(
"unresolved-asset",
`Resource #${line.value.resourceId} does not exist.`,
);
}
massPerUnit.set(resource.id, resource.massPerUnit ?? 0);
lines.push(line.value);
}
preflightHqDeposit(
{
blueprint: hq.type,
installedModules: hq.installedModules ?? null,
storedEntries: hq.storedResources ?? [],
voidEntries: hq.voidStorage ?? [],
massPerUnit,
},
lines,
);
const auditUser = await resolveAuditUser(payload, ext.actorId, req);
for (const line of lines) {
const result = await addResourceInternal(payload, auditUser, hqId, line.resourceId, line.amount);
if (!result.success) {
throw new SettlementFailure("hq-rule-violation", result.error ?? "HQ deposit rejected.");
}
}
if (ext.actorId) {
const total = lines.reduce((sum, line) => sum + line.amount, 0);
await notifyUser(payload, {
userId: ext.actorId,
type: "operation:extraction",
title: "Logistics extraction complete",
message: `${total.toLocaleString()} resource unit(s) deposited at HQ "${hq.name}".`,
link: `/logistics/structures/${hqId}`,
});
}
}
async function settlePlayerExtraction(
payload: Payload,
effect: OperationEffect,
ext: ExtractionPayloadV1,
rawLines: unknown[],
req?: PayloadRequest,
): Promise<void> {
const userId = ext.actorId as number;
const user = (await findByIdOrNull(payload, "users", userId, req)) as unknown as User | null;
if (!user) throw new SettlementFailure("unresolved-user", `User #${userId} does not exist.`);
const lines: LockerFitLine[] = [];
for (const raw of rawLines) {
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 });
}
const locker = await ensureLockerStorage(payload, userId);
const lockerFull = (await payload.findByID({
collection: "locker-storages",
id: locker.id,
depth: 1,
overrideAccess: true,
req,
})) as unknown as LockerStorage;
const grid = await getLockerGridDimensions(payload);
preflightLockerCredit(lockerFull, grid, lines);
for (const line of lines) {
try {
await creditLockerQuantity(payload, userId, line.assetId, line.quantity);
} catch (error) {
throw new SettlementFailure(
"locker-full",
error instanceof Error ? error.message : "Locker credit failed.",
);
}
await emitGameEvent(payload, {
type: EventTypes.LockerAddItem,
message: `Extraction secured ${line.quantity}x ${line.asset.name} in ${user.displayName || user.username}'s locker`,
actor: userId,
targetCollection: "locker-storages",
targetId: locker.id,
data: {
assetId: line.assetId,
quantity: line.quantity,
operationId: ext.operationId,
effectKey: effect.effectKey,
},
});
}
const total = lines.reduce((sum, line) => sum + line.quantity, 0);
await notifyUser(payload, {
userId,
type: "operation:extraction",
title: "Extraction complete",
message: `${total.toLocaleString()} item unit(s) secured in your locker.`,
link: "/locker",
});
}
async function settleLinkedReservation(
payload: Payload,
ext: ExtractionPayloadV1,
effectId: number,
req?: PayloadRequest,
): Promise<string | null> {
try {
await settleOperationReservation(
payload,
{ reservationKey: ext.reservationKey },
{
outcome: "settled",
reason: "extracted",
consumed: ext.consumed,
effectId,
actorId: ext.actorId,
},
req,
);
return null;
} catch (error) {
// Downstream accounting must not roll back a final deposit: the
// reservation stays reserved for a later sweep or replay.
const message = error instanceof Error ? error.message : "unknown error";
payload.logger.warn(
`[Operations] Reservation ${ext.reservationKey} settlement deferred: ${message}`,
);
return `reservation ${ext.reservationKey} settlement deferred: ${message}`;
}
}

View file

@ -0,0 +1,163 @@
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;
}

View file

@ -0,0 +1,173 @@
import type { Asset, GameStructure, LockerStorage, Resource } from "@/payload-types";
import { effectiveMaxMass } from "@/lib/base/modules";
import { checkStorageDeposit, storageViolationMessage } from "@/lib/storageRules";
import type { StorageRulesSource } from "@/lib/storageRules";
import {
findLockerEmptySpot,
getLockerItemFootprint,
lockerIsStackable,
lockerMaxStack,
toLockerGridItems,
} from "@/lib/locker";
import { SettlementFailure } from "./settlementTypes";
import type { LogisticsExtractionLine, PlayerExtractionLine } from "./settlementTypes";
/**
* Pure preflight checks for extraction settlement. Every recoverable failure
* mode (storage rules, HQ capacity, locker space) is detected here, BEFORE
* any write, so a rejected effect never leaves a partial deposit or credit
* behind. The canonical writers (`addResourceInternal`,
* `creditLockerQuantity`) still re-run their own checks; these simulations
* exist to keep the whole effect atomic.
*/
type StorageEntry = NonNullable<GameStructure["storedResources"]>[number];
export interface HqDepositContext {
blueprint: StorageRulesSource & {
maxCapacityMass?: number | null;
moduleSlots?: number | null;
moduleOptions?:
| {
name: string;
effects?: { type: string; value?: number | null }[] | null;
}[]
| null;
};
installedModules?: { name: string }[] | null;
storedEntries: StorageEntry[];
voidEntries: StorageEntry[];
massPerUnit: Map<number, number>;
}
function entryResourceId(entry: StorageEntry): number | null {
const r = entry.resource;
if (typeof r === "number") return r;
if (r && typeof r === "object" && "id" in r) return r.id;
return null;
}
/**
* Verify every logistics line against the HQ's storage rules and mass
* capacity, accumulating inbound amounts so multi-line payloads are checked
* as a whole. Throws SettlementFailure on the first violation.
*/
export function preflightHqDeposit(
ctx: HqDepositContext,
lines: LogisticsExtractionLine[],
): void {
const maxMass = effectiveMaxMass(ctx.blueprint, ctx.installedModules);
const allEntries = [...ctx.storedEntries, ...ctx.voidEntries];
let currentMass = 0;
for (const entry of ctx.storedEntries) {
const rid = entryResourceId(entry);
if (rid === null) continue;
currentMass += (ctx.massPerUnit.get(rid) ?? 0) * entry.amount;
}
const inbound = new Map<number, number>();
let addedMass = 0;
for (const line of lines) {
const alreadyInbound = inbound.get(line.resourceId) ?? 0;
const currentStored =
allEntries
.filter((e) => entryResourceId(e) === line.resourceId)
.reduce((sum, e) => sum + e.amount, 0) + alreadyInbound;
const check = checkStorageDeposit(ctx.blueprint, line.resourceId, line.amount, currentStored);
if (!check.allowed) {
throw new SettlementFailure(
"hq-rule-violation",
storageViolationMessage(`Resource #${line.resourceId}`, check),
);
}
inbound.set(line.resourceId, alreadyInbound + line.amount);
addedMass += (ctx.massPerUnit.get(line.resourceId) ?? 0) * line.amount;
}
if (maxMass && currentMass + addedMass > maxMass) {
throw new SettlementFailure(
"hq-capacity",
`HQ capacity exceeded. Available: ${Math.max(0, maxMass - currentMass).toLocaleString()} kg, needed: ${addedMass.toLocaleString()} kg.`,
);
}
}
export interface LockerFitLine extends PlayerExtractionLine {
asset: Asset;
}
/**
* Simulate `creditLockerQuantity` for every player line against a cloned
* locker, mirroring its merge-then-place order exactly. Throws
* SettlementFailure("locker-full") when any unit cannot fit; returns void
* when the whole manifest fits, so the real credit cannot partially apply.
*/
export function preflightLockerCredit(
locker: LockerStorage,
grid: { width: number; height: number },
lines: LockerFitLine[],
): void {
const items = [...(locker.items ?? [])];
const gridItems = toLockerGridItems(items);
for (const line of lines) {
const { asset } = line;
const stackable = lockerIsStackable(asset);
const maxStack = lockerMaxStack(asset);
const footprint = getLockerItemFootprint(asset, false);
let remaining = line.quantity;
if (stackable) {
for (const entry of gridItems) {
if (remaining <= 0) break;
if (entry.assetId !== line.assetId) continue;
const space =
maxStack === Infinity
? remaining
: Math.max(0, Math.min(maxStack - entry.quantity, remaining));
if (space <= 0) continue;
const idx = items.findIndex((e) => e.id === entry.id);
if (idx >= 0) {
items[idx] = { ...items[idx], quantity: (items[idx].quantity ?? 0) + space };
entry.quantity += space;
remaining -= space;
}
}
}
while (remaining > 0) {
const stackAmount = Math.min(remaining, maxStack === Infinity ? remaining : maxStack);
const spot = findLockerEmptySpot(
gridItems,
footprint.width,
footprint.height,
grid.width,
grid.height,
);
if (!spot) {
throw new SettlementFailure(
"locker-full",
`No space in the locker for ${asset.name}. Needs ${footprint.width}x${footprint.height} free cells.`,
);
}
gridItems.push({
id: `sim-${gridItems.length}`,
assetId: line.assetId,
quantity: stackAmount,
gridX: spot.x,
gridY: spot.y,
rotated: false,
asset,
});
remaining -= stackAmount;
}
}
}
/** Mass per unit lookup helper for resolved resource docs. */
export function massPerUnitOf(resources: Map<number, Resource>, resourceId: number): number {
return resources.get(resourceId)?.massPerUnit ?? 0;
}

View file

@ -0,0 +1,130 @@
import {
aggregateCargo,
relId,
type ReservationRow,
type SettlementAmounts,
type SettlementConsumed,
type SettlementOutcome,
} from "./allocationTypes";
/**
* Deterministic settlement math for operation reservations. Pure module: no
* Payload imports. Consumed amounts are validated against what was reserved
* (throwing before any write when they exceed it); returned amounts are
* always `reserved - consumed`, so a settlement can never return more than
* was earmarked.
*/
export function reservedAmounts(row: ReservationRow): SettlementAmounts {
const cargoTotals = new Map<number, number>();
const cargoOrder: number[] = [];
for (const c of row.cargo ?? []) {
const rid = relId(c.resource);
if (rid == null) continue;
if (!cargoTotals.has(rid)) cargoOrder.push(rid);
cargoTotals.set(rid, (cargoTotals.get(rid) ?? 0) + c.amount);
}
return {
personnel: (row.personnel ?? [])
.map((p) => relId(p.user))
.filter((id): id is number => id != null),
vehicles: (row.vehicles ?? [])
.map((v) => relId(v.vehicle))
.filter((id): id is number => id != null),
cargo: cargoOrder.map((rid) => ({ resource: rid, amount: cargoTotals.get(rid) ?? 0 })),
budget: row.budget?.amount ?? 0,
};
}
/**
* Validate and normalize the consumed amounts for a `settled` outcome.
* Cancellation, no-show, and expiry consume nothing by definition.
*/
export function normalizeConsumed(
reserved: SettlementAmounts,
outcome: SettlementOutcome,
consumed?: SettlementConsumed,
): SettlementAmounts {
if (outcome !== "settled") {
return { personnel: [], vehicles: [], cargo: [], budget: 0 };
}
const errors: string[] = [];
const personnel = consumed?.personnel ?? [];
for (const id of personnel) {
if (!reserved.personnel.includes(id)) {
errors.push(`Consumed personnel #${id} was not reserved.`);
}
}
const vehicles = consumed?.vehicleIds ?? [];
for (const id of vehicles) {
if (!reserved.vehicles.includes(id)) {
errors.push(`Consumed vehicle #${id} was not reserved.`);
}
}
const reservedCargo = aggregateCargo(
reserved.cargo.map((c) => ({ resourceId: c.resource, amount: c.amount })),
);
const consumedCargo = aggregateCargo(consumed?.cargo ?? []);
for (const [resourceId, amount] of consumedCargo) {
if (!Number.isInteger(amount) || amount < 0) {
errors.push(
`Consumed cargo amount for resource #${resourceId} must be a non-negative integer.`,
);
continue;
}
const reservedAmount = reservedCargo.get(resourceId) ?? 0;
if (amount > reservedAmount) {
errors.push(
`Consumed cargo for resource #${resourceId} (${amount}) exceeds the reserved amount (${reservedAmount}).`,
);
}
}
const budgetAmount = consumed?.budgetAmount ?? 0;
if (!Number.isInteger(budgetAmount) || budgetAmount < 0) {
errors.push("Consumed budget must be a non-negative integer.");
} else if (budgetAmount > reserved.budget) {
errors.push(
`Consumed budget (${budgetAmount}) exceeds the reserved amount (${reserved.budget}).`,
);
}
if (errors.length > 0) {
throw new Error(`Settlement rejected: ${errors.join(" ")}`);
}
const cargoOrder: number[] = [];
for (const line of consumed?.cargo ?? []) {
if (!cargoOrder.includes(line.resourceId)) cargoOrder.push(line.resourceId);
}
return {
personnel,
vehicles,
cargo: cargoOrder
.map((rid) => ({ resource: rid, amount: consumedCargo.get(rid) ?? 0 }))
.filter((line) => line.amount > 0),
budget: budgetAmount,
};
}
export function computeReturned(
reserved: SettlementAmounts,
consumed: SettlementAmounts,
): SettlementAmounts {
const consumedCargo = new Map(consumed.cargo.map((c) => [c.resource, c.amount]));
return {
personnel: reserved.personnel.filter((id) => !consumed.personnel.includes(id)),
vehicles: reserved.vehicles.filter((id) => !consumed.vehicles.includes(id)),
cargo: reserved.cargo
.map((line) => ({
resource: line.resource,
amount: line.amount - (consumedCargo.get(line.resource) ?? 0),
}))
.filter((line) => line.amount > 0),
budget: reserved.budget - consumed.budget,
};
}
export function defaultReason(outcome: SettlementOutcome): string {
return outcome === "settled" ? "completed" : outcome;
}

View file

@ -0,0 +1,212 @@
import type { SettlementConsumed } from "./allocationTypes";
import {
DEFAULT_RECOVERY_POLICY,
EXTRACTION_KINDS,
INVENTORY_SNAPSHOT_ITEM_LIMIT,
LOSS_REASONS,
OPERATION_SETTLEMENT_VERSION,
} from "./settlementTypes";
import type {
ExtractionKind,
ExtractionPayloadV1,
InventoryLossPayloadV1,
LogisticsExtractionLine,
LossReason,
ObjectivePayloadV1,
OperationEndPayloadV1,
ParseFailure,
ParseResult,
PlayerExtractionLine,
RecoveryPolicy,
SettlementFailureCode,
} from "./settlementTypes";
/**
* Boundary parsers for the settlement contract: untrusted effect payloads
* are parsed into typed contract values exactly once, here. Anything the
* parsers reject becomes a failed effect; the handlers never re-validate.
*/
function asRecord(raw: unknown): Record<string, unknown> | null {
return typeof raw === "object" && raw !== null && !Array.isArray(raw)
? (raw as Record<string, unknown>)
: null;
}
function positiveInt(value: unknown): number | null {
return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : null;
}
function fail(code: SettlementFailureCode, message: string): ParseFailure {
return { ok: false, code, message };
}
function parseItems(raw: unknown): ParseResult<unknown[]> {
if (raw === undefined || raw === null) return { ok: true, value: [] };
if (!Array.isArray(raw)) return fail("malformed-payload", "items must be an array.");
if (raw.length > INVENTORY_SNAPSHOT_ITEM_LIMIT) {
return fail(
"malformed-payload",
`items exceeds the snapshot limit of ${INVENTORY_SNAPSHOT_ITEM_LIMIT} lines.`,
);
}
return { ok: true, value: raw };
}
function parseOptionalId(p: Record<string, unknown>, key: string): number | undefined {
const value = p[key];
return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : undefined;
}
/** Parse a logistics line: `{ resourceId, amount }`, both positive integers. */
export function parseLogisticsLine(raw: unknown): ParseResult<LogisticsExtractionLine> {
const line = asRecord(raw);
const resourceId = positiveInt(line?.resourceId);
const amount = positiveInt(line?.amount);
if (!line || resourceId === null || amount === null) {
return fail(
"malformed-asset",
"A logistics line must carry a positive integer resourceId and amount.",
);
}
return { ok: true, value: { resourceId, amount } };
}
/** Parse a player line: `{ assetId, quantity }`, both positive integers. */
export function parsePlayerLine(raw: unknown): ParseResult<PlayerExtractionLine> {
const line = asRecord(raw);
const assetId = positiveInt(line?.assetId);
const quantity = positiveInt(line?.quantity);
if (!line || assetId === null || quantity === null) {
return fail(
"malformed-asset",
"A player extraction line must carry a positive integer assetId and quantity.",
);
}
return { ok: true, value: { assetId, quantity } };
}
export function parseExtractionPayload(raw: unknown): ParseResult<ExtractionPayloadV1> {
const p = asRecord(raw);
if (!p) return fail("malformed-payload", "Extraction effect payload must be an object.");
const operationId = typeof p.operationId === "string" ? p.operationId : "unknown";
const version = typeof p.version === "number" ? p.version : OPERATION_SETTLEMENT_VERSION;
const items = parseItems(p.items);
if (!items.ok) return items;
let kind: ExtractionKind | undefined;
if (p.kind !== undefined && p.kind !== null) {
if (!EXTRACTION_KINDS.includes(p.kind as ExtractionKind)) {
return fail("malformed-payload", `Unknown extraction kind "${String(p.kind)}".`);
}
kind = p.kind as ExtractionKind;
}
if (kind === undefined && items.value.length > 0) {
return fail("malformed-payload", "Extraction kind is required when items are present.");
}
const actorId = parseOptionalId(p, "actorId");
const hqStructureId = parseOptionalId(p, "hqStructureId");
if (kind === "player" && actorId === undefined) {
return fail("malformed-payload", "A player extraction requires a positive integer actorId.");
}
if (kind === "logistics" && hqStructureId === undefined) {
return fail(
"malformed-payload",
"A logistics extraction requires a positive integer hqStructureId.",
);
}
let consumed: SettlementConsumed | undefined;
if (p.consumed !== undefined && p.consumed !== null) {
const c = asRecord(p.consumed);
if (!c) return fail("malformed-payload", "consumed must be an object.");
consumed = c as SettlementConsumed;
}
let recoveryPolicy: RecoveryPolicy = DEFAULT_RECOVERY_POLICY;
if (p.recoveryPolicy !== undefined && p.recoveryPolicy !== null) {
const rp = asRecord(p.recoveryPolicy);
if (!rp || typeof rp.type !== "string") {
return fail("malformed-payload", "recoveryPolicy must be an object with a type.");
}
recoveryPolicy = rp as unknown as RecoveryPolicy;
}
const reservationKey =
typeof p.reservationKey === "string" && p.reservationKey ? p.reservationKey : undefined;
return {
ok: true,
value: {
operationId,
version,
kind,
actorId,
hqStructureId,
reservationKey,
items: items.value,
consumed,
recoveryPolicy,
},
};
}
export function parseInventoryLossPayload(raw: unknown): ParseResult<InventoryLossPayloadV1> {
const p = asRecord(raw);
if (!p) return fail("malformed-payload", "Inventory effect payload must be an object.");
const operationId = typeof p.operationId === "string" ? p.operationId : "unknown";
const version = typeof p.version === "number" ? p.version : OPERATION_SETTLEMENT_VERSION;
const items = parseItems(p.items);
if (!items.ok) return items;
let reason: LossReason | undefined;
if (p.reason !== undefined && p.reason !== null) {
if (!LOSS_REASONS.includes(p.reason as LossReason)) {
return fail("malformed-payload", `Unknown inventory reason "${String(p.reason)}".`);
}
reason = p.reason as LossReason;
}
return {
ok: true,
value: {
operationId,
version,
actorId: parseOptionalId(p, "actorId"),
reason,
items: items.value,
},
};
}
export function parseObjectivePayload(raw: unknown): ParseResult<ObjectivePayloadV1> {
const p = asRecord(raw);
if (!p) return fail("malformed-payload", "Objective effect payload must be an object.");
return {
ok: true,
value: {
operationId: typeof p.operationId === "string" ? p.operationId : "unknown",
actorId: parseOptionalId(p, "actorId"),
objective: p.objective ?? null,
},
};
}
export function parseOperationEndPayload(raw: unknown): ParseResult<OperationEndPayloadV1> {
const p = asRecord(raw);
if (!p) return fail("malformed-payload", "Operation end payload must be an object.");
return {
ok: true,
value: {
operationId: typeof p.operationId === "string" ? p.operationId : "unknown",
actorId: parseOptionalId(p, "actorId"),
outcome: typeof p.outcome === "string" ? p.outcome : undefined,
zoneId: parseOptionalId(p, "zoneId"),
factionId: parseOptionalId(p, "factionId"),
missionId: parseOptionalId(p, "missionId"),
},
};
}

View file

@ -0,0 +1,31 @@
import type { Payload, PayloadRequest } from "payload";
type CollectionSlug =
| "users"
| "assets"
| "resources"
| "game-structures"
| "operation-effects"
| "operation-events"
| "arma-sync-events";
/**
* Non-throwing id lookup: findByID's NotFound throw inside the hook's open
* transaction poisons the later effect update; find returns empty instead.
*/
export async function findByIdOrNull(
payload: Payload,
collection: CollectionSlug,
id: number,
req?: PayloadRequest,
): Promise<{ id: number } | null> {
const result = await payload.find({
collection,
where: { id: { equals: id } },
limit: 1,
depth: 0,
overrideAccess: true,
req,
});
return (result.docs[0] as { id: number } | undefined) ?? null;
}

View file

@ -0,0 +1,135 @@
import type { SettlementConsumed } from "./allocationTypes";
/**
* Operation settlement contracts (version 1). Pure module: no Payload
* imports, safe to import anywhere.
*
* Extraction contract: a validated `operation.extraction` event carries a
* `kind` ("player" | "logistics") and an item manifest. A logistics
* extraction deposits resources at the operation's designated HQ structure;
* a player extraction credits assets to the extracting user's personal
* locker. An extraction event with no `kind` and no `items` is record-only:
* AAR evidence with no inventory movement (the legacy `{ player }` shape).
*
* Asset resolution rule: resource/asset ids come only from the payload. A
* line with a missing, non-integer, or non-positive id is `malformed-asset`;
* an id that does not resolve to an existing catalog doc is
* `unresolved-asset`. Either rejects the whole effect; the settlement never
* guesses an id.
*
* Stack behavior: locker credits go through `creditLockerQuantity`, which
* merges stackables into existing stacks before filling empty grid spots.
* HQ deposits go through `addResourceInternal`, which tops up existing
* stacks of the same resource before placing new grid entries (or
* flat-lists on gridless blueprints).
*
* Death-loss rule: a validated `operation.inventory` event with
* `reason: "death" | "loss"` records loss evidence for the unextracted
* inventory the server reports, verbatim. It never mutates lockers, HQ
* storage, or the profile scoreboard: the profile XP hook awards XP for ANY
* scoreboard key, so a scoreboard write would be a reward, which the plan
* prohibits. Losses are AAR facts (effect row + event log + notification).
*/
/** Current version of the settlement contract. */
export const OPERATION_SETTLEMENT_VERSION = 1;
/** Maximum item lines accepted in one extraction/loss payload. */
export const INVENTORY_SNAPSHOT_ITEM_LIMIT = 100;
export const EXTRACTION_KINDS = ["player", "logistics"] as const;
export type ExtractionKind = (typeof EXTRACTION_KINDS)[number];
export const LOSS_REASONS = ["death", "loss"] as const;
export type LossReason = (typeof LOSS_REASONS)[number];
/** operation.end outcomes that trigger the mission-failure sweep. */
export const MISSION_FAILURE_OUTCOMES = ["failed", "failure"] as const;
/**
* Recovery/insurance extension point. Typed but unimplemented: only "none"
* is supported in this contract version; any other policy rejects the
* effect instead of silently ignoring it.
*/
export type RecoveryPolicy =
| { type: "none" }
| { type: "insurance"; provider: string }
| { type: "recovery-team"; teamId: string };
export const DEFAULT_RECOVERY_POLICY: RecoveryPolicy = { type: "none" };
export const SETTLEMENT_FAILURE_CODES = [
"malformed-payload",
"malformed-asset",
"unresolved-asset",
"unresolved-user",
"unsupported-recovery-policy",
"hq-not-found",
"hq-capacity",
"hq-rule-violation",
"locker-full",
"internal-error",
] as const;
export type SettlementFailureCode = (typeof SETTLEMENT_FAILURE_CODES)[number];
/** Typed settlement failure: caught by the dispatcher into a failed effect. */
export class SettlementFailure extends Error {
constructor(
readonly code: SettlementFailureCode,
message: string,
) {
super(message);
this.name = "SettlementFailure";
}
}
export type ParseFailure = { ok: false; code: SettlementFailureCode; message: string };
export type ParseResult<T> = { ok: true; value: T } | ParseFailure;
export interface LogisticsExtractionLine {
resourceId: number;
amount: number;
}
export interface PlayerExtractionLine {
assetId: number;
quantity: number;
}
export interface ExtractionPayloadV1 {
operationId: string;
version: number;
kind?: ExtractionKind;
actorId?: number;
hqStructureId?: number;
reservationKey?: string;
items?: unknown[];
consumed?: SettlementConsumed;
recoveryPolicy?: RecoveryPolicy;
}
export interface InventoryLossPayloadV1 {
operationId: string;
version: number;
actorId?: number;
reason?: LossReason;
items?: unknown[];
}
export interface ObjectivePayloadV1 {
operationId: string;
actorId?: number;
objective?: unknown;
}
export interface OperationEndPayloadV1 {
operationId: string;
actorId?: number;
outcome?: string;
/** Optional zone the operation's strategic pressure applies to (task 7). */
zoneId?: number;
/** Optional faction driving or receiving the pressure. */
factionId?: number;
/** Optional mission the operation was scoped to. */
missionId?: number;
}

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,850 @@
import { getPayload, Payload } from "payload";
import config from "@/payload.config";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { processOperationEvent } from "@/lib/operations/process";
import { reserveOperationAssets, type SettlementRecord } from "@/lib/operations/allocation";
import { ensureLockerStorage, getLockerGridDimensions } from "@/lib/locker";
import type { GameStructure, LockerStorage, OperationReservation, Structure } from "@/payload-types";
let payload: Payload;
const RUN = `settle-${Date.now().toString(36)}`;
const LEXICAL_EMPTY = {
root: {
children: [{ text: "" }],
direction: null,
format: "" as const,
indent: 0,
type: "text",
version: 1,
},
};
// ---------------------------------------------------------------------------
// Fixture tracking for cleanup
// ---------------------------------------------------------------------------
const reservationIds: number[] = [];
const syncEventIds: number[] = [];
const opEventIds: number[] = [];
const opEffectIds: number[] = [];
const userIds: number[] = [];
const roleIds: number[] = [];
const gameStructureIds: number[] = [];
const blueprintIds: number[] = [];
const resourceIds: number[] = [];
const assetIds: number[] = [];
const missionIds: number[] = [];
const campaignIds: number[] = [];
const mapIds: number[] = [];
let serverId: number;
let commandUserId: number;
let extractorId: number;
let deathUserId: number;
let fullLockerUserId: number;
let malformedUserId: number;
let mapId: number;
let campaignId: number;
let missionId: number;
let resourceAId: number;
let stackableAssetId: number;
let fillerAssetId: number;
let hqBlueprintId: number;
let cappedBlueprintId: number;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function relId(value: unknown): number {
return typeof value === "object" && value !== null
? (value as { id: number }).id
: (value as number);
}
function settlementOf(doc: OperationReservation): SettlementRecord {
return doc.settlement as unknown as SettlementRecord;
}
async function makeUser(label: string, roleDocIds?: number[]): Promise<number> {
const user = await payload.create({
collection: "users",
data: {
username: `${RUN}-${label}`,
discordUsername: `${RUN}-${label}`,
displayName: `${RUN} ${label}`,
steamId: `7656119${Math.floor(Math.random() * 1e9)}`,
password: "Test1234",
roles: ["user"],
...(roleDocIds && roleDocIds.length > 0 ? { roleDocs: roleDocIds } : {}),
},
overrideAccess: true,
depth: 0,
});
userIds.push(user.id);
return user.id;
}
async function makeStructure(
label: string,
blueprintId: number,
stock: { resourceId: number; amount: number }[] = [],
): Promise<number> {
const site = await payload.create({
collection: "game-structures",
data: {
name: `${RUN} ${label} ${gameStructureIds.length}`,
type: blueprintId,
map: mapId,
coordinates: [100 + gameStructureIds.length, 100],
constructionStatus: "complete",
storedResources: stock.map((s, i) => ({
resource: s.resourceId,
amount: s.amount,
gridX: 0,
gridY: i,
rotated: false,
})),
},
overrideAccess: true,
depth: 0,
});
gameStructureIds.push(site.id);
return site.id;
}
async function storedAmountAt(siteId: number, resourceId: number): Promise<number> {
const doc = (await payload.findByID({
collection: "game-structures",
id: siteId,
depth: 0,
overrideAccess: true,
})) as unknown as GameStructure;
return (doc.storedResources ?? [])
.filter((r) => relId(r.resource) === resourceId)
.reduce((sum, r) => sum + r.amount, 0);
}
type JsonObject = { [k: string]: unknown };
async function emitRawEvent(eventId: string, type: string, eventPayload: JsonObject): Promise<void> {
const syncDoc = await payload.create({
collection: "arma-sync-events",
data: {
eventId,
server: serverId,
type,
occurredAt: new Date().toISOString(),
payload: eventPayload,
},
overrideAccess: true,
depth: 0,
});
syncEventIds.push(syncDoc.id);
const opEvents = await payload.find({
collection: "operation-events",
where: { sourceEvent: { equals: syncDoc.id } },
limit: 1,
depth: 0,
overrideAccess: true,
});
for (const doc of opEvents.docs) opEventIds.push(doc.id);
}
async function effectsForOperation(operationId: string) {
const effects = await payload.find({
collection: "operation-effects",
where: { operationId: { equals: operationId } },
limit: 20,
depth: 0,
overrideAccess: true,
});
for (const doc of effects.docs) {
if (!opEffectIds.includes(doc.id)) opEffectIds.push(doc.id);
}
return effects.docs;
}
async function notificationsFor(userId: number, type: string) {
const found = await payload.find({
collection: "user-notifications",
where: { and: [{ user: { equals: userId } }, { type: { equals: type } }] },
limit: 20,
depth: 0,
overrideAccess: true,
});
return found.docs;
}
async function lockerOf(userId: number): Promise<LockerStorage | null> {
const found = await payload.find({
collection: "locker-storages",
where: { ownerUser: { equals: userId } },
limit: 1,
depth: 1,
overrideAccess: true,
});
return (found.docs[0] as unknown as LockerStorage | undefined) ?? null;
}
async function findReservation(key: string): Promise<OperationReservation | null> {
const res = await payload.find({
collection: "operation-reservations",
where: { reservationKey: { equals: key } },
limit: 1,
depth: 0,
overrideAccess: true,
});
return (res.docs[0] as OperationReservation | undefined) ?? null;
}
// ---------------------------------------------------------------------------
// Setup / teardown
// ---------------------------------------------------------------------------
beforeAll(async () => {
const payloadConfig = await config;
payload = await getPayload({ config: payloadConfig });
const superRole = await payload.create({
collection: "roles",
data: { name: `${RUN} Command`, slug: `${RUN}-command`, isSuperuser: true },
overrideAccess: true,
depth: 0,
});
roleIds.push(superRole.id);
commandUserId = await makeUser("command", [superRole.id]);
extractorId = await makeUser("extractor");
deathUserId = await makeUser("dead");
fullLockerUserId = await makeUser("packed");
malformedUserId = await makeUser("glitch");
const map = await payload.create({
collection: "maps",
data: { name: `${RUN} Map`, worldSizeWidth: 8192, worldSizeHeight: 8192, basemapMode: "image" },
overrideAccess: true,
depth: 0,
});
mapId = map.id;
mapIds.push(map.id);
const campaign = await payload.create({
collection: "campaigns",
data: {
name: `${RUN} Campaign`,
summary: "Settlement test campaign",
status: "concept",
campaignMode: "custom",
},
overrideAccess: true,
depth: 0,
});
campaignId = campaign.id;
campaignIds.push(campaign.id);
const mission = await payload.create({
collection: "missions",
data: {
name: `${RUN} Mission`,
codeName: `${RUN}-mission`,
summary: "Settlement test mission",
operationType: "main",
classification: {
map: mapId,
missionType: "PvE",
campaign: campaignId,
startDateTime: new Date(Date.now() + 86_400_000).toISOString(),
estimatedDuration: 60,
},
ownershipAndStatus: {
authors: [commandUserId],
status: "Scheduled",
visibility: "unit",
},
missionRoles: { maxPlayers: 16 },
gameDetails: { serverDetails: { serverIp: "127.0.0.1", serverPort: 2302 } },
briefing: [],
},
overrideAccess: true,
depth: 0,
});
missionId = mission.id;
missionIds.push(mission.id);
const server = await payload.create({
collection: "game-servers",
data: { serverId: `${RUN}-srv`, name: `${RUN} Server`, status: "online" },
overrideAccess: true,
depth: 0,
});
serverId = server.id;
const resource = await payload.create({
collection: "resources",
data: {
name: `${RUN} Supplies`,
codeName: `${RUN}-supplies`,
approvalStatus: "in_progress",
type: "physical",
baseValue: 1,
rarity: "common",
unitOfMeasure: "kg",
massPerUnit: 1,
gridWidth: 1,
gridHeight: 1,
},
overrideAccess: true,
depth: 0,
});
resourceAId = resource.id;
resourceIds.push(resource.id);
const stackableAsset = await payload.create({
collection: "assets",
data: {
name: `${RUN} Ammo Box`,
className: `${RUN}_ammo_box`,
assetType: "magazine",
approvalStatus: "in_progress",
crafting: { craftable: false, craftingData: { craftingTimePerUnit: 1, batchSize: 1 } },
storageDimensions: { gridWidth: 1, gridHeight: 1, stackable: true, maxStackSize: 10 },
},
overrideAccess: true,
depth: 0,
});
stackableAssetId = stackableAsset.id;
assetIds.push(stackableAsset.id);
const fillerAsset = await payload.create({
collection: "assets",
data: {
name: `${RUN} Rifle`,
className: `${RUN}_rifle`,
assetType: "weapon",
approvalStatus: "in_progress",
crafting: { craftable: false, craftingData: { craftingTimePerUnit: 1, batchSize: 1 } },
storageDimensions: { gridWidth: 1, gridHeight: 1, stackable: false },
},
overrideAccess: true,
depth: 0,
});
fillerAssetId = fillerAsset.id;
assetIds.push(fillerAsset.id);
const hqBlueprint = await payload.create({
collection: "structures",
data: {
name: `${RUN} HQ Depot`,
codeName: `${RUN}-hq-depot`,
approvalStatus: "in_progress",
description: LEXICAL_EMPTY as unknown as Structure["description"],
category: "logistics",
materials: [{ resource: resourceAId, amount: 1 }],
constructionDurationMinutes: 1,
terrainType: "land",
maxHealth: 100,
},
overrideAccess: true,
depth: 0,
});
hqBlueprintId = hqBlueprint.id;
blueprintIds.push(hqBlueprint.id);
const cappedBlueprint = await payload.create({
collection: "structures",
data: {
name: `${RUN} Cramped Shed`,
codeName: `${RUN}-cramped-shed`,
approvalStatus: "in_progress",
description: LEXICAL_EMPTY as unknown as Structure["description"],
category: "logistics",
materials: [{ resource: resourceAId, amount: 1 }],
constructionDurationMinutes: 1,
terrainType: "land",
maxHealth: 100,
maxCapacityMass: 5,
},
overrideAccess: true,
depth: 0,
});
cappedBlueprintId = cappedBlueprint.id;
blueprintIds.push(cappedBlueprint.id);
});
afterAll(async () => {
if (!payload) return;
for (const id of reservationIds) {
await payload.delete({ collection: "operation-reservations", id, overrideAccess: true }).catch(() => {});
}
for (const id of opEffectIds) {
await payload.delete({ collection: "operation-effects", id, overrideAccess: true }).catch(() => {});
}
for (const id of opEventIds) {
await payload.delete({ collection: "operation-events", id, overrideAccess: true }).catch(() => {});
}
for (const id of syncEventIds) {
await payload.delete({ collection: "arma-sync-events", id, overrideAccess: true }).catch(() => {});
}
for (const id of gameStructureIds) {
await payload.delete({ collection: "game-structures", id, overrideAccess: true }).catch(() => {});
}
for (const id of blueprintIds) {
await payload.delete({ collection: "structures", id, overrideAccess: true }).catch(() => {});
}
for (const id of assetIds) {
await payload.delete({ collection: "assets", id, overrideAccess: true }).catch(() => {});
}
for (const id of resourceIds) {
await payload.delete({ collection: "resources", id, overrideAccess: true }).catch(() => {});
}
for (const id of missionIds) {
await payload.delete({ collection: "missions", id, overrideAccess: true }).catch(() => {});
}
for (const id of campaignIds) {
await payload.delete({ collection: "campaigns", id, overrideAccess: true }).catch(() => {});
}
for (const id of mapIds) {
await payload.delete({ collection: "maps", id, overrideAccess: true }).catch(() => {});
}
const eventLogs = await payload.find({
collection: "game-event-logs",
where: { actor: { in: userIds } },
limit: 200,
depth: 0,
overrideAccess: true,
});
for (const doc of eventLogs.docs) {
await payload.delete({ collection: "game-event-logs", id: doc.id, overrideAccess: true }).catch(() => {});
}
for (const userId of userIds) {
const notifications = await payload.find({
collection: "user-notifications",
where: { user: { equals: userId } },
limit: 50,
depth: 0,
overrideAccess: true,
});
for (const doc of notifications.docs) {
await payload.delete({ collection: "user-notifications", id: doc.id, overrideAccess: true }).catch(() => {});
}
const lockers = await payload.find({
collection: "locker-storages",
where: { ownerUser: { equals: userId } },
limit: 5,
depth: 0,
overrideAccess: true,
});
for (const doc of lockers.docs) {
await payload.delete({ collection: "locker-storages", id: doc.id, overrideAccess: true }).catch(() => {});
}
const profiles = await payload.find({
collection: "profiles",
where: { user: { equals: userId } },
limit: 5,
depth: 0,
overrideAccess: true,
});
for (const profile of profiles.docs) {
await payload.delete({ collection: "profiles", id: profile.id, overrideAccess: true }).catch(() => {});
}
await payload.delete({ collection: "users", id: userId, overrideAccess: true }).catch(() => {});
}
for (const id of roleIds) {
await payload.delete({ collection: "roles", id, overrideAccess: true }).catch(() => {});
}
if (serverId) {
await payload.delete({ collection: "game-servers", id: serverId, overrideAccess: true }).catch(() => {});
}
});
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("Operation settlement", () => {
it("settles a logistics extraction into one provenance-linked HQ deposit and settles its reservation", async () => {
const operationId = `${RUN}-op-log`;
const originId = await makeStructure("origin", hqBlueprintId, [
{ resourceId: resourceAId, amount: 100 },
]);
const hqId = await makeStructure("hq", hqBlueprintId);
const reservationKey = `${RUN}-res-log`;
const reservation = await reserveOperationAssets(payload, {
reservationKey,
operationId,
actorId: commandUserId,
cargo: [{ resourceId: resourceAId, amount: 40 }],
originId,
});
reservationIds.push(reservation.id);
await emitRawEvent(`${RUN}-evt-log`, "operation.extraction", {
operationId,
version: 1,
actorId: extractorId,
kind: "logistics",
hqStructureId: hqId,
reservationKey,
items: [{ resourceId: resourceAId, amount: 25 }],
consumed: { cargo: [{ resourceId: resourceAId, amount: 15 }] },
});
const effects = await effectsForOperation(operationId);
expect(effects).toHaveLength(1);
const effect = effects[0];
expect(effect.effectType).toBe("operation.extraction-recorded");
expect(effect.status).toBe("applied");
expect(effect.appliedAt).toBeTruthy();
// Exactly one deposit: 25 units at the designated HQ, none at the origin.
expect(await storedAmountAt(hqId, resourceAId)).toBe(25);
expect(await storedAmountAt(originId, resourceAId)).toBe(100);
// The linked reservation settled exactly once from the effect.
const settled = await findReservation(reservationKey);
expect(settled?.status).toBe("settled");
const record = settlementOf(settled as OperationReservation);
expect(record.reason).toBe("extracted");
expect(record.consumed.cargo).toEqual([{ resource: resourceAId, amount: 15 }]);
expect(record.returned.cargo).toEqual([{ resource: resourceAId, amount: 25 }]);
expect(record.effectId).toBe(effect.id);
expect(relId(settled?.settledByEffect)).toBe(effect.id);
const notifications = await notificationsFor(extractorId, "operation:extraction");
expect(notifications.length).toBeGreaterThanOrEqual(1);
});
it("settles a player extraction into one provenance-linked locker credit and settles its reservation", async () => {
const operationId = `${RUN}-op-player`;
const reservationKey = `${RUN}-res-player`;
const reservation = await reserveOperationAssets(payload, {
reservationKey,
operationId,
actorId: commandUserId,
missionId,
personnel: [{ userId: extractorId }],
});
reservationIds.push(reservation.id);
await emitRawEvent(`${RUN}-evt-player`, "operation.extraction", {
operationId,
version: 1,
actorId: extractorId,
kind: "player",
reservationKey,
items: [{ assetId: stackableAssetId, quantity: 3 }],
});
const effects = await effectsForOperation(operationId);
expect(effects).toHaveLength(1);
expect(effects[0].status).toBe("applied");
const locker = await lockerOf(extractorId);
expect(locker).not.toBeNull();
const entries = (locker?.items ?? []).filter((e) => relId(e.asset) === stackableAssetId);
expect(entries).toHaveLength(1);
expect(entries[0].quantity).toBe(3);
const settled = await findReservation(reservationKey);
expect(settled?.status).toBe("settled");
const record = settlementOf(settled as OperationReservation);
expect(record.reason).toBe("extracted");
expect(record.consumed).toEqual({ personnel: [], vehicles: [], cargo: [], budget: 0 });
expect(record.returned.personnel).toEqual([extractorId]);
expect(record.effectId).toBe(effects[0].id);
const notifications = await notificationsFor(extractorId, "operation:extraction");
expect(notifications.length).toBeGreaterThanOrEqual(1);
});
it("applies a duplicate extraction event as a no-op with no second deposit", async () => {
const operationId = `${RUN}-op-dup`;
const hqId = await makeStructure("hq-dup", hqBlueprintId);
const syncDoc = await payload.create({
collection: "arma-sync-events",
data: {
eventId: `${RUN}-evt-dup`,
server: serverId,
type: "operation.extraction",
occurredAt: new Date().toISOString(),
payload: {
operationId,
version: 1,
actorId: extractorId,
kind: "logistics",
hqStructureId: hqId,
items: [{ resourceId: resourceAId, amount: 10 }],
},
},
overrideAccess: true,
depth: 0,
});
syncEventIds.push(syncDoc.id);
const opEvents = await payload.find({
collection: "operation-events",
where: { sourceEvent: { equals: syncDoc.id } },
limit: 1,
depth: 0,
overrideAccess: true,
});
for (const doc of opEvents.docs) opEventIds.push(doc.id);
// A replay of the same raw event is idempotent by sourceEvent.
await processOperationEvent(payload, {
id: syncDoc.id,
eventId: syncDoc.eventId,
server: serverId,
type: syncDoc.type,
occurredAt: syncDoc.occurredAt,
payload: syncDoc.payload,
});
const effects = await effectsForOperation(operationId);
expect(effects).toHaveLength(1);
expect(effects[0].status).toBe("applied");
expect(await storedAmountAt(hqId, resourceAId)).toBe(10);
});
it("records death loss evidence for unextracted inventory only, with no reward", async () => {
const operationId = `${RUN}-op-death`;
await emitRawEvent(`${RUN}-evt-death`, "operation.inventory", {
operationId,
version: 1,
actorId: deathUserId,
reason: "death",
items: [{ assetId: stackableAssetId, quantity: 2 }],
});
const effects = await effectsForOperation(operationId);
expect(effects).toHaveLength(1);
expect(effects[0].effectType).toBe("operation.inventory-recorded");
expect(effects[0].status).toBe("applied");
// No inventory was credited anywhere.
const locker = await lockerOf(deathUserId);
expect(locker).toBeNull();
// Loss evidence: notification + combat:death event log entry.
const notifications = await notificationsFor(deathUserId, "operation:death");
expect(notifications).toHaveLength(1);
const deathEvents = await payload.find({
collection: "game-event-logs",
where: { and: [{ type: { equals: "combat:death" } }, { targetId: { equals: deathUserId } }] },
limit: 10,
depth: 0,
overrideAccess: true,
});
expect(deathEvents.docs).toHaveLength(1);
// No scoreboard write: the XP hook would turn any scoreboard key into XP.
const profiles = await payload.find({
collection: "profiles",
where: { user: { equals: deathUserId } },
limit: 1,
depth: 0,
overrideAccess: true,
});
const scoreboard = (profiles.docs[0]?.progression?.scoreboard ?? {}) as Record<string, number | null>;
expect(scoreboard.deaths ?? 0).toBe(0);
expect(scoreboard.infantryKills ?? 0).toBe(0);
});
it("fails recoverably on a full HQ with no partial deposit", async () => {
const operationId = `${RUN}-op-full-hq`;
const hqId = await makeStructure("hq-capped", cappedBlueprintId);
await emitRawEvent(`${RUN}-evt-full-hq`, "operation.extraction", {
operationId,
version: 1,
actorId: extractorId,
kind: "logistics",
hqStructureId: hqId,
items: [{ resourceId: resourceAId, amount: 10 }],
});
const effects = await effectsForOperation(operationId);
expect(effects).toHaveLength(1);
expect(effects[0].status).toBe("failed");
expect(effects[0].reconciliationNotes).toContain("hq-capacity");
expect(await storedAmountAt(hqId, resourceAId)).toBe(0);
});
it("fails recoverably on a full locker with no partial credit", async () => {
const operationId = `${RUN}-op-full-locker`;
const locker = await ensureLockerStorage(payload, fullLockerUserId);
const grid = await getLockerGridDimensions(payload);
const fill = [];
for (let y = 0; y < grid.height; y++) {
for (let x = 0; x < grid.width; x++) {
fill.push({ asset: fillerAssetId, quantity: 1, gridX: x, gridY: y, rotated: false });
}
}
await payload.update({
collection: "locker-storages",
id: locker.id,
data: { items: fill },
overrideAccess: true,
depth: 0,
});
await emitRawEvent(`${RUN}-evt-full-locker`, "operation.extraction", {
operationId,
version: 1,
actorId: fullLockerUserId,
kind: "player",
items: [{ assetId: stackableAssetId, quantity: 1 }],
});
const effects = await effectsForOperation(operationId);
expect(effects).toHaveLength(1);
expect(effects[0].status).toBe("failed");
expect(effects[0].reconciliationNotes).toContain("locker-full");
const after = await lockerOf(fullLockerUserId);
expect(after?.items ?? []).toHaveLength(grid.width * grid.height);
});
it("rejects malformed and unresolved asset references without guessing", async () => {
const unresolvedOp = `${RUN}-op-unresolved`;
await emitRawEvent(`${RUN}-evt-unresolved`, "operation.extraction", {
operationId: unresolvedOp,
version: 1,
actorId: malformedUserId,
kind: "player",
items: [{ assetId: 99999999, quantity: 1 }],
});
const unresolvedEffects = await effectsForOperation(unresolvedOp);
expect(unresolvedEffects).toHaveLength(1);
expect(unresolvedEffects[0].status).toBe("failed");
expect(unresolvedEffects[0].reconciliationNotes).toContain("unresolved-asset");
const malformedOp = `${RUN}-op-malformed`;
await emitRawEvent(`${RUN}-evt-malformed`, "operation.extraction", {
operationId: malformedOp,
version: 1,
actorId: malformedUserId,
kind: "player",
items: [{ assetId: "abc", quantity: 1 }],
});
const malformedEffects = await effectsForOperation(malformedOp);
expect(malformedEffects).toHaveLength(1);
expect(malformedEffects[0].status).toBe("failed");
expect(malformedEffects[0].reconciliationNotes).toContain("malformed-asset");
// Nothing was credited; validation failed before the locker was even created.
expect(await lockerOf(malformedUserId)).toBeNull();
});
it("preserves an extraction deposit after a later mission failure", async () => {
const operationId = `${RUN}-op-fail`;
const originId = await makeStructure("origin-fail", hqBlueprintId, [
{ resourceId: resourceAId, amount: 100 },
]);
const hqId = await makeStructure("hq-fail", hqBlueprintId);
const extractedKey = `${RUN}-fail-extracted`;
const extracted = await reserveOperationAssets(payload, {
reservationKey: extractedKey,
operationId,
actorId: commandUserId,
cargo: [{ resourceId: resourceAId, amount: 10 }],
originId,
});
reservationIds.push(extracted.id);
const pendingKey = `${RUN}-fail-pending`;
const pending = await reserveOperationAssets(payload, {
reservationKey: pendingKey,
operationId,
actorId: commandUserId,
cargo: [{ resourceId: resourceAId, amount: 10 }],
originId,
});
reservationIds.push(pending.id);
await emitRawEvent(`${RUN}-evt-fail-extract`, "operation.extraction", {
operationId,
version: 1,
actorId: extractorId,
kind: "logistics",
hqStructureId: hqId,
reservationKey: extractedKey,
items: [{ resourceId: resourceAId, amount: 4 }],
consumed: { cargo: [{ resourceId: resourceAId, amount: 6 }] },
});
await emitRawEvent(`${RUN}-evt-fail-end`, "operation.end", {
operationId,
version: 1,
actorId: commandUserId,
outcome: "failed",
});
const effects = await effectsForOperation(operationId);
expect(effects).toHaveLength(2);
const extractionEffect = effects.find((e) => e.effectType === "operation.extraction-recorded");
const endEffect = effects.find((e) => e.effectType === "operation.ended");
expect(extractionEffect?.status).toBe("applied");
expect(endEffect?.status).toBe("applied");
expect(endEffect?.reconciliationNotes).toContain("mission-failed");
// The deposit is final: mission failure never claws it back.
expect(await storedAmountAt(hqId, resourceAId)).toBe(4);
// The extraction-settled reservation is untouched by the failure sweep.
const extractedDoc = await findReservation(extractedKey);
expect(extractedDoc?.status).toBe("settled");
expect(settlementOf(extractedDoc as OperationReservation).reason).toBe("extracted");
// The still-reserved row settles as mission-failed via the end effect.
const failedDoc = await findReservation(pendingKey);
expect(failedDoc?.status).toBe("settled");
const failedRecord = settlementOf(failedDoc as OperationReservation);
expect(failedRecord.reason).toBe("mission-failed");
expect(failedRecord.returned.cargo).toEqual([{ resource: resourceAId, amount: 10 }]);
expect(failedRecord.effectId).toBe(endEffect?.id);
// The extraction effect was never reversed.
const freshExtraction = await payload.findByID({
collection: "operation-effects",
id: extractionEffect?.id as number,
depth: 0,
overrideAccess: true,
});
expect(freshExtraction.status).toBe("applied");
expect(freshExtraction.reversedBy).toBeNull();
});
it("updates the AAR for an objective-only operation without touching inventory", async () => {
const operationId = `${RUN}-op-obj`;
const hqId = await makeStructure("hq-obj", hqBlueprintId);
const lockerBefore = await lockerOf(extractorId);
const countBefore = (lockerBefore?.items ?? []).reduce((sum, e) => sum + e.quantity, 0);
await emitRawEvent(`${RUN}-evt-obj`, "operation.objective", {
operationId,
version: 1,
actorId: extractorId,
objective: { id: "obj-1", name: "Secure the depot", status: "complete" },
});
const effects = await effectsForOperation(operationId);
expect(effects).toHaveLength(1);
expect(effects[0].effectType).toBe("operation.objective-updated");
expect(effects[0].status).toBe("applied");
const notifications = await notificationsFor(extractorId, "operation:objective");
expect(notifications).toHaveLength(1);
// No inventory movement anywhere.
expect(await storedAmountAt(hqId, resourceAId)).toBe(0);
const lockerAfter = await lockerOf(extractorId);
const countAfter = (lockerAfter?.items ?? []).reduce((sum, e) => sum + e.quantity, 0);
expect(countAfter).toBe(countBefore);
});
});