1
0
Fork 0
polaris-task-force/tests/int/operation-settlement.int.spec.ts
Z8MB1E 67c993ca10 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>
2026-09-20 02:43:47 -04:00

850 lines
28 KiB
TypeScript

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