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