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>
850 lines
28 KiB
TypeScript
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);
|
|
});
|
|
});
|