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:
parent
cc23742dd8
commit
67c993ca10
17 changed files with 34621 additions and 0 deletions
146
src/collections/operations/OperationReservations.ts
Normal file
146
src/collections/operations/OperationReservations.ts
Normal 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.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
211
src/lib/operations/allocation.ts
Normal file
211
src/lib/operations/allocation.ts
Normal 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;
|
||||||
|
}
|
||||||
92
src/lib/operations/allocationSweep.ts
Normal file
92
src/lib/operations/allocationSweep.ts
Normal 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;
|
||||||
|
}
|
||||||
85
src/lib/operations/allocationTypes.ts
Normal file
85
src/lib/operations/allocationTypes.ts
Normal 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;
|
||||||
|
}
|
||||||
232
src/lib/operations/allocationValidate.ts
Normal file
232
src/lib/operations/allocationValidate.ts
Normal 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(" ")}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
95
src/lib/operations/settlement.ts
Normal file
95
src/lib/operations/settlement.ts
Normal 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}".`);
|
||||||
|
}
|
||||||
|
}
|
||||||
258
src/lib/operations/settlementExtraction.ts
Normal file
258
src/lib/operations/settlementExtraction.ts
Normal 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}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
163
src/lib/operations/settlementFacts.ts
Normal file
163
src/lib/operations/settlementFacts.ts
Normal 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;
|
||||||
|
}
|
||||||
173
src/lib/operations/settlementFit.ts
Normal file
173
src/lib/operations/settlementFit.ts
Normal 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;
|
||||||
|
}
|
||||||
130
src/lib/operations/settlementMath.ts
Normal file
130
src/lib/operations/settlementMath.ts
Normal 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;
|
||||||
|
}
|
||||||
212
src/lib/operations/settlementParse.ts
Normal file
212
src/lib/operations/settlementParse.ts
Normal 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"),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
31
src/lib/operations/settlementResolve.ts
Normal file
31
src/lib/operations/settlementResolve.ts
Normal 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;
|
||||||
|
}
|
||||||
135
src/lib/operations/settlementTypes.ts
Normal file
135
src/lib/operations/settlementTypes.ts
Normal 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;
|
||||||
|
}
|
||||||
30621
src/migrations/20260918_194842_add_operation_allocation.json
Normal file
30621
src/migrations/20260918_194842_add_operation_allocation.json
Normal file
File diff suppressed because it is too large
Load diff
116
src/migrations/20260918_194842_add_operation_allocation.ts
Normal file
116
src/migrations/20260918_194842_add_operation_allocation.ts
Normal file
File diff suppressed because one or more lines are too long
1071
tests/int/operation-allocation.int.spec.ts
Normal file
1071
tests/int/operation-allocation.int.spec.ts
Normal file
File diff suppressed because it is too large
Load diff
850
tests/int/operation-settlement.int.spec.ts
Normal file
850
tests/int/operation-settlement.int.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue