1
0
Fork 0

feat(operations): supply-box sync store and handler

This commit is contained in:
Jason Fraley 2026-09-21 07:40:44 -04:00
parent 42066e9d13
commit f2bd5418b2
9 changed files with 32168 additions and 1 deletions

View file

@ -1,5 +1,6 @@
import { CollectionConfig } from "payload"; import { CollectionConfig } from "payload";
import { processPlayerKill } from "@/lib/arma-bridge/processSyncEvents"; import { processPlayerKill } from "@/lib/arma-bridge/processSyncEvents";
import { processBoxEvent } from "@/lib/arma-bridge/crates";
import { isOperationLedgerEnabled } from "@/lib/operations/rollout"; import { isOperationLedgerEnabled } from "@/lib/operations/rollout";
import { processOperationEvent } from "@/lib/operations/process"; import { processOperationEvent } from "@/lib/operations/process";
@ -22,6 +23,16 @@ export const ArmaSyncEvents: CollectionConfig = {
req.payload.logger.error(`[KillTracker] Failed to process kill event ${doc.eventId}: ${err}`); req.payload.logger.error(`[KillTracker] Failed to process kill event ${doc.eventId}: ${err}`);
}); });
} }
if (doc.type.startsWith("box.")) {
// Awaited (not fire-and-forget) so the crate writes join the raw-event
// transaction; processBoxEvent never throws, so ingestion cannot be
// rolled back by crate sync.
try {
await processBoxEvent(req.payload, doc, req);
} catch (err) {
req.payload.logger.error(`[CrateSync] Failed to process box event ${doc.eventId}: ${err}`);
}
}
}, },
], ],
}, },

View file

@ -0,0 +1,73 @@
import { CollectionConfig } from "payload";
/**
* Supply crates synced from the Arma bridge mod (`box.*` event family).
*
* Rows are written only by the crate-sync handler (`src/lib/arma-bridge/crates.ts`)
* via `overrideAccess`; the admin panel and REST API are read-only for logged-in
* users. `box.deleted` never hard-deletes: it marks the row `status: deleted`
* and a later `box.register`/`box.sync` reactivates it. Every row links to the
* raw `arma-sync-events` row that produced its current state (`sourceEvent`).
*/
export const SupplyBoxes: CollectionConfig = {
slug: "supply-boxes",
admin: {
group: "Server",
useAsTitle: "boxId",
defaultColumns: ["boxId", "boxClass", "status", "server", "lastSyncedAt"],
},
access: {
read: ({ req }) => !!req.user,
create: () => false,
update: () => false,
delete: () => false,
},
fields: [
{ name: "boxId", type: "text", required: true, unique: true, index: true },
{
name: "sessionId",
type: "text",
required: true,
index: true,
admin: {
description:
"Bridge session generation id (worldName + systemTime) extracted from the boxId; used to detect stale generations after a mission restart.",
},
},
{
name: "server",
type: "relationship",
relationTo: "game-servers",
required: true,
index: true,
admin: {
description:
"Owning game server, resolved from the authenticated ingestion event. Never populated from payload content.",
},
},
{ name: "boxClass", type: "text" },
{ name: "displayName", type: "text" },
{ name: "location", type: "json" },
{ name: "contents", type: "json" },
{ name: "lastSyncedAt", type: "date", required: true },
{
name: "status",
type: "select",
required: true,
defaultValue: "active",
options: ["active", "deleted"],
index: true,
},
{
name: "sourceEvent",
type: "relationship",
relationTo: "arma-sync-events",
required: true,
index: true,
admin: {
description:
"Raw arma-sync-events row that produced the current state; provenance back to the exact mod event.",
},
},
],
};

View file

@ -0,0 +1,209 @@
import type { Payload, PayloadRequest } from "payload";
import type { RawSyncEvent } from "@/lib/operations/types";
import { asJson, type BridgeJsonValue } from "@/lib/arma-bridge/json";
/**
* One cargo line of a supply crate: item class name + quantity.
* Deliberately a type alias, not an interface: Payload json fields type as
* `{ [k: string]: unknown } | unknown[] | ...`, and interfaces lack implicit
* index signatures, so an interface would not be assignable to the field.
*/
export type SupplyBoxContent = { class: string; count: number };
/** World-space position of a crate, in meters (Arma convention). */
export type BoxLocation = { x: number; y: number; z: number };
/**
* Parses a session-scoped box id `box:<sessionId>:<seq>`.
* sessionId is `worldName + systemTime` (no colons), seq is numeric.
* Returns null for anything that does not match.
*/
export function sessionIdFromBoxId(boxId: string): string | null {
const match = /^box:(.+):\d+$/.exec(boxId);
return match ? match[1] : null;
}
function pgErrorCode(error: unknown): string | undefined {
const cause = (error as { cause?: { code?: string } })?.cause;
return cause?.code ?? (error as { code?: string })?.code;
}
function stringAt(payload: unknown[], index: number): string | null {
const value = payload[index];
return typeof value === "string" && value.length > 0 ? value : null;
}
function locationFrom(payload: unknown[]): BridgeJsonValue | null {
const x = payload[3];
const y = payload[4];
const z = payload[5];
if (typeof x !== "number" || typeof y !== "number" || typeof z !== "number") return null;
return asJson({ x, y, z }, null);
}
function contentsFrom(value: unknown): BridgeJsonValue | null {
if (!Array.isArray(value)) return null;
const contents: SupplyBoxContent[] = [];
for (const entry of value) {
if (!Array.isArray(entry) || entry.length < 2) continue;
const itemClass = entry[0];
const count = entry[1];
if (typeof itemClass !== "string" || itemClass.length === 0) continue;
if (typeof count !== "number" || !Number.isFinite(count) || count < 0) continue;
contents.push({ class: itemClass, count });
}
return asJson(contents, null);
}
interface BoxPatch {
boxId: string;
sessionId: string;
serverId: number;
sourceEventId: number;
boxClass?: string | null;
displayName?: string | null;
location?: BridgeJsonValue | null;
contents?: BridgeJsonValue | null;
status: "active" | "deleted";
}
/**
* Upserts the SupplyBoxes row for a boxId. Every write carries the owning
* server (resolved from the authenticated event, never from payload content)
* and the raw event that produced this state. `box.deleted` never hard-deletes:
* it marks the row deleted and a later register/sync reactivates it.
*/
async function upsertBox(payload: Payload, req: PayloadRequest | undefined, patch: BoxPatch): Promise<void> {
const existing = await payload.find({
collection: "supply-boxes",
where: { boxId: { equals: patch.boxId } },
limit: 1,
depth: 0,
overrideAccess: true,
req,
});
const data = {
boxId: patch.boxId,
sessionId: patch.sessionId,
server: patch.serverId,
boxClass: patch.boxClass ?? null,
displayName: patch.displayName ?? null,
location: patch.location ?? null,
contents: patch.contents ?? null,
lastSyncedAt: new Date().toISOString(),
status: patch.status,
sourceEvent: patch.sourceEventId,
};
if (existing.docs.length > 0) {
await payload.update({
collection: "supply-boxes",
id: existing.docs[0].id,
data,
overrideAccess: true,
depth: 0,
req,
});
return;
}
try {
await payload.create({
collection: "supply-boxes",
data,
overrideAccess: true,
depth: 0,
req,
});
} catch (error) {
if (pgErrorCode(error) === "23505") {
// Unique-constraint race with a concurrent request: the row already
// exists and the concurrent write is equivalent or newer.
payload.logger.info(`[CrateSync] Duplicate box ${patch.boxId} skipped (race)`);
return;
}
throw error;
}
}
/**
* Consumes one `box.*` raw sync event into the SupplyBoxes store.
*
* Fully defensive: malformed events are logged and skipped, never thrown, so
* the raw-event ingestion transaction is never rolled back by crate sync.
* Callers pass `req` so every write joins the raw-event transaction.
*
* Payload shapes (flat arrays, see docs/arma-bridge/crate-sync.md):
* box.register = [boxId, boxClass, displayName, locationX, locationY, locationZ]
* box.sync = [boxId, [[itemClass, count], ...]]
* box.deleted = [boxId]
*/
export async function processBoxEvent(
payload: Payload,
raw: RawSyncEvent,
req?: PayloadRequest,
): Promise<void> {
try {
// The SupplyBoxes.server relationship is REQUIRED and only ever resolved
// from an authenticated event; a null server is a malformed no-op.
if (typeof raw.server !== "number") {
payload.logger.warn(`[CrateSync] Box event ${raw.eventId} has no server; skipping (malformed)`);
return;
}
if (!Array.isArray(raw.payload)) {
payload.logger.warn(`[CrateSync] Box event ${raw.eventId} payload is not an array; skipping`);
return;
}
const boxId = raw.payload[0];
if (typeof boxId !== "string" || boxId.length === 0) {
payload.logger.warn(`[CrateSync] Box event ${raw.eventId} has no boxId; skipping`);
return;
}
const sessionId = sessionIdFromBoxId(boxId);
if (!sessionId) {
payload.logger.warn(`[CrateSync] Box event ${raw.eventId} has malformed boxId "${boxId}"; skipping`);
return;
}
switch (raw.type) {
case "box.register":
await upsertBox(payload, req, {
boxId,
sessionId,
serverId: raw.server,
sourceEventId: raw.id,
boxClass: stringAt(raw.payload, 1),
displayName: stringAt(raw.payload, 2),
location: locationFrom(raw.payload),
status: "active",
});
return;
case "box.sync":
await upsertBox(payload, req, {
boxId,
sessionId,
serverId: raw.server,
sourceEventId: raw.id,
contents: contentsFrom(raw.payload[1]),
status: "active",
});
return;
case "box.deleted":
await upsertBox(payload, req, {
boxId,
sessionId,
serverId: raw.server,
sourceEventId: raw.id,
status: "deleted",
});
return;
default:
payload.logger.warn(`[CrateSync] Unknown box event type "${raw.type}" for ${raw.eventId}; skipping`);
}
} catch (error) {
payload.logger.error(
`[CrateSync] Failed to process box event ${raw.eventId}: ${error instanceof Error ? error.message : "unknown error"}`,
);
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,53 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
CREATE TYPE "public"."enum_supply_boxes_status" AS ENUM('active', 'deleted');
CREATE TABLE "supply_boxes" (
"id" serial PRIMARY KEY NOT NULL,
"box_id" varchar NOT NULL,
"session_id" varchar NOT NULL,
"server_id" integer NOT NULL,
"box_class" varchar,
"display_name" varchar,
"location" jsonb,
"contents" jsonb,
"last_synced_at" timestamp(3) with time zone NOT NULL,
"status" "enum_supply_boxes_status" DEFAULT 'active' NOT NULL,
"source_event_id" integer NOT NULL,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
);
ALTER TABLE "payload_mcp_api_keys" ADD COLUMN "supply_boxes_find" boolean DEFAULT false;
ALTER TABLE "payload_mcp_api_keys" ADD COLUMN "supply_boxes_create" boolean DEFAULT false;
ALTER TABLE "payload_mcp_api_keys" ADD COLUMN "supply_boxes_update" boolean DEFAULT false;
ALTER TABLE "payload_mcp_api_keys" ADD COLUMN "supply_boxes_delete" boolean DEFAULT false;
ALTER TABLE "payload_locked_documents_rels" ADD COLUMN "supply_boxes_id" integer;
ALTER TABLE "supply_boxes" ADD CONSTRAINT "supply_boxes_server_id_game_servers_id_fk" FOREIGN KEY ("server_id") REFERENCES "public"."game_servers"("id") ON DELETE set null ON UPDATE no action;
ALTER TABLE "supply_boxes" ADD CONSTRAINT "supply_boxes_source_event_id_arma_sync_events_id_fk" FOREIGN KEY ("source_event_id") REFERENCES "public"."arma_sync_events"("id") ON DELETE set null ON UPDATE no action;
CREATE UNIQUE INDEX "supply_boxes_box_id_idx" ON "supply_boxes" USING btree ("box_id");
CREATE INDEX "supply_boxes_session_id_idx" ON "supply_boxes" USING btree ("session_id");
CREATE INDEX "supply_boxes_server_idx" ON "supply_boxes" USING btree ("server_id");
CREATE INDEX "supply_boxes_status_idx" ON "supply_boxes" USING btree ("status");
CREATE INDEX "supply_boxes_source_event_idx" ON "supply_boxes" USING btree ("source_event_id");
CREATE INDEX "supply_boxes_updated_at_idx" ON "supply_boxes" USING btree ("updated_at");
CREATE INDEX "supply_boxes_created_at_idx" ON "supply_boxes" USING btree ("created_at");
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_supply_boxes_fk" FOREIGN KEY ("supply_boxes_id") REFERENCES "public"."supply_boxes"("id") ON DELETE cascade ON UPDATE no action;
CREATE INDEX "payload_locked_documents_rels_supply_boxes_id_idx" ON "payload_locked_documents_rels" USING btree ("supply_boxes_id");`)
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "supply_boxes" DISABLE ROW LEVEL SECURITY;
DROP TABLE "supply_boxes" CASCADE;
ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT "payload_locked_documents_rels_supply_boxes_fk";
DROP INDEX "payload_locked_documents_rels_supply_boxes_id_idx";
ALTER TABLE "payload_mcp_api_keys" DROP COLUMN "supply_boxes_find";
ALTER TABLE "payload_mcp_api_keys" DROP COLUMN "supply_boxes_create";
ALTER TABLE "payload_mcp_api_keys" DROP COLUMN "supply_boxes_update";
ALTER TABLE "payload_mcp_api_keys" DROP COLUMN "supply_boxes_delete";
ALTER TABLE "payload_locked_documents_rels" DROP COLUMN "supply_boxes_id";
DROP TYPE "public"."enum_supply_boxes_status";`)
}

View file

@ -76,6 +76,7 @@ import * as migration_20260918_180851_add_operation_ledger from './20260918_1808
import * as migration_20260918_194842_add_operation_allocation from './20260918_194842_add_operation_allocation'; import * as migration_20260918_194842_add_operation_allocation from './20260918_194842_add_operation_allocation';
import * as migration_20260918_222549_add_operation_readiness from './20260918_222549_add_operation_readiness'; import * as migration_20260918_222549_add_operation_readiness from './20260918_222549_add_operation_readiness';
import * as migration_20260920_044743_add_zone_pressure from './20260920_044743_add_zone_pressure'; import * as migration_20260920_044743_add_zone_pressure from './20260920_044743_add_zone_pressure';
import * as migration_20260921_113443_add_supply_boxes from './20260921_113443_add_supply_boxes';
export const migrations = [ export const migrations = [
{ {
@ -466,6 +467,11 @@ export const migrations = [
{ {
up: migration_20260920_044743_add_zone_pressure.up, up: migration_20260920_044743_add_zone_pressure.up,
down: migration_20260920_044743_add_zone_pressure.down, down: migration_20260920_044743_add_zone_pressure.down,
name: '20260920_044743_add_zone_pressure' name: '20260920_044743_add_zone_pressure',
},
{
up: migration_20260921_113443_add_supply_boxes.up,
down: migration_20260921_113443_add_supply_boxes.down,
name: '20260921_113443_add_supply_boxes'
}, },
]; ];

View file

@ -130,6 +130,7 @@ export interface Config {
'arma-sync-events': ArmaSyncEvent; 'arma-sync-events': ArmaSyncEvent;
'arma-commands': ArmaCommand; 'arma-commands': ArmaCommand;
'respawn-tickets': RespawnTicket; 'respawn-tickets': RespawnTicket;
'supply-boxes': SupplyBox;
'operation-events': OperationEvent; 'operation-events': OperationEvent;
'operation-effects': OperationEffect; 'operation-effects': OperationEffect;
'operation-reservations': OperationReservation; 'operation-reservations': OperationReservation;
@ -217,6 +218,7 @@ export interface Config {
'arma-sync-events': ArmaSyncEventsSelect<false> | ArmaSyncEventsSelect<true>; 'arma-sync-events': ArmaSyncEventsSelect<false> | ArmaSyncEventsSelect<true>;
'arma-commands': ArmaCommandsSelect<false> | ArmaCommandsSelect<true>; 'arma-commands': ArmaCommandsSelect<false> | ArmaCommandsSelect<true>;
'respawn-tickets': RespawnTicketsSelect<false> | RespawnTicketsSelect<true>; 'respawn-tickets': RespawnTicketsSelect<false> | RespawnTicketsSelect<true>;
'supply-boxes': SupplyBoxesSelect<false> | SupplyBoxesSelect<true>;
'operation-events': OperationEventsSelect<false> | OperationEventsSelect<true>; 'operation-events': OperationEventsSelect<false> | OperationEventsSelect<true>;
'operation-effects': OperationEffectsSelect<false> | OperationEffectsSelect<true>; 'operation-effects': OperationEffectsSelect<false> | OperationEffectsSelect<true>;
'operation-reservations': OperationReservationsSelect<false> | OperationReservationsSelect<true>; 'operation-reservations': OperationReservationsSelect<false> | OperationReservationsSelect<true>;
@ -4260,6 +4262,50 @@ export interface RespawnTicket {
updatedAt: string; updatedAt: string;
createdAt: string; createdAt: string;
} }
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "supply-boxes".
*/
export interface SupplyBox {
id: number;
boxId: string;
/**
* Bridge session generation id (worldName + systemTime) extracted from the boxId; used to detect stale generations after a mission restart.
*/
sessionId: string;
/**
* Owning game server, resolved from the authenticated ingestion event. Never populated from payload content.
*/
server: number | GameServer;
boxClass?: string | null;
displayName?: string | null;
location?:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
contents?:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
lastSyncedAt: string;
status: 'active' | 'deleted';
/**
* Raw arma-sync-events row that produced the current state; provenance back to the exact mod event.
*/
sourceEvent: number | ArmaSyncEvent;
updatedAt: string;
createdAt: string;
}
/** /**
* This interface was referenced by `Config`'s JSON-Schema * This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "operation-events". * via the `definition` "operation-events".
@ -5845,6 +5891,24 @@ export interface PayloadMcpApiKey {
*/ */
delete?: boolean | null; delete?: boolean | null;
}; };
supplyBoxes?: {
/**
* Allow clients to find supply-boxes.
*/
find?: boolean | null;
/**
* Allow clients to create supply-boxes.
*/
create?: boolean | null;
/**
* Allow clients to update supply-boxes.
*/
update?: boolean | null;
/**
* Allow clients to delete supply-boxes.
*/
delete?: boolean | null;
};
operationEvents?: { operationEvents?: {
/** /**
* Allow clients to find operation-events. * Allow clients to find operation-events.
@ -6286,6 +6350,10 @@ export interface PayloadLockedDocument {
relationTo: 'respawn-tickets'; relationTo: 'respawn-tickets';
value: number | RespawnTicket; value: number | RespawnTicket;
} | null) } | null)
| ({
relationTo: 'supply-boxes';
value: number | SupplyBox;
} | null)
| ({ | ({
relationTo: 'operation-events'; relationTo: 'operation-events';
value: number | OperationEvent; value: number | OperationEvent;
@ -8176,6 +8244,24 @@ export interface RespawnTicketsSelect<T extends boolean = true> {
updatedAt?: T; updatedAt?: T;
createdAt?: T; createdAt?: T;
} }
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "supply-boxes_select".
*/
export interface SupplyBoxesSelect<T extends boolean = true> {
boxId?: T;
sessionId?: T;
server?: T;
boxClass?: T;
displayName?: T;
location?: T;
contents?: T;
lastSyncedAt?: T;
status?: T;
sourceEvent?: T;
updatedAt?: T;
createdAt?: T;
}
/** /**
* This interface was referenced by `Config`'s JSON-Schema * This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "operation-events_select". * via the `definition` "operation-events_select".
@ -8893,6 +8979,14 @@ export interface PayloadMcpApiKeysSelect<T extends boolean = true> {
update?: T; update?: T;
delete?: T; delete?: T;
}; };
supplyBoxes?:
| T
| {
find?: T;
create?: T;
update?: T;
delete?: T;
};
operationEvents?: operationEvents?:
| T | T
| { | {

View file

@ -28,6 +28,7 @@ import { GameServers } from "@/collections/server/GameServers";
import { ArmaSyncEvents } from "@/collections/server/ArmaSyncEvents"; import { ArmaSyncEvents } from "@/collections/server/ArmaSyncEvents";
import { ArmaCommands } from "@/collections/server/ArmaCommands"; import { ArmaCommands } from "@/collections/server/ArmaCommands";
import { RespawnTickets } from "@/collections/server/RespawnTickets"; import { RespawnTickets } from "@/collections/server/RespawnTickets";
import { SupplyBoxes } from "@/collections/server/SupplyBoxes";
import { OperationEvents } from "@/collections/operations/OperationEvents"; import { OperationEvents } from "@/collections/operations/OperationEvents";
import { OperationEffects } from "@/collections/operations/OperationEffects"; import { OperationEffects } from "@/collections/operations/OperationEffects";
import { OperationReservations } from "@/collections/operations/OperationReservations"; import { OperationReservations } from "@/collections/operations/OperationReservations";
@ -313,6 +314,7 @@ const collections = [
ArmaSyncEvents, ArmaSyncEvents,
ArmaCommands, ArmaCommands,
RespawnTickets, RespawnTickets,
SupplyBoxes,
// Operations // Operations
OperationEvents, OperationEvents,

View file

@ -0,0 +1,259 @@
import { getPayload, Payload } from "payload";
import config from "@/payload.config";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import type { GameServer, SupplyBox } from "@/payload-types";
import { processBoxEvent, sessionIdFromBoxId } from "@/lib/arma-bridge/crates";
let payload: Payload;
const RUN = `t5-${Date.now().toString(36)}`;
describe("Supply box crate sync", () => {
let serverId: number | null = null;
const syncEventIds: number[] = [];
const opEventIds: number[] = [];
const boxIds: number[] = [];
beforeAll(async () => {
const payloadConfig = await config;
payload = await getPayload({ config: payloadConfig });
const server = (await payload.create({
collection: "game-servers",
data: {
serverId: `${RUN}-srv`,
name: `${RUN} Server`,
status: "online",
},
overrideAccess: true,
depth: 0,
})) as unknown as GameServer;
serverId = server.id;
});
afterAll(async () => {
if (!payload) return;
for (const id of boxIds) {
await payload.delete({ collection: "supply-boxes", 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(() => {});
}
if (serverId) {
await payload.delete({ collection: "game-servers", id: serverId, overrideAccess: true }).catch(() => {});
}
});
// Creates a raw arma-sync-events row as a provenance anchor (the supply-boxes
// sourceEvent relationship requires a real row id) with a neutral type so the
// afterChange hook does not process it as a box event, then runs the handler
// directly against it.
async function emitRaw(eventId: string, type: string, eventPayload: unknown[]): Promise<number> {
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);
return syncDoc.id;
}
async function runBoxEvent(eventId: string, type: string, eventPayload: unknown[]): Promise<number> {
const rawId = await emitRaw(eventId, "fixture", []);
await processBoxEvent(payload, {
id: rawId,
eventId,
server: serverId!,
type,
occurredAt: new Date().toISOString(),
payload: eventPayload,
});
return rawId;
}
async function fetchBox(boxId: string): Promise<SupplyBox | null> {
const found = await payload.find({
collection: "supply-boxes",
where: { boxId: { equals: boxId } },
limit: 1,
depth: 0,
overrideAccess: true,
});
if (found.docs.length === 0) return null;
boxIds.push(found.docs[0].id);
return found.docs[0] as unknown as SupplyBox;
}
it("registers a box with provenance from a box.register event", async () => {
const boxId = `box:${RUN}-reg:1`;
const rawId = await runBoxEvent(`${RUN}-evt-reg`, "box.register", [
boxId,
"Box_NATO_Wps_F",
"Ammo Crate",
100,
200,
0,
]);
const box = await fetchBox(boxId);
expect(box).not.toBeNull();
expect(box!.boxClass).toBe("Box_NATO_Wps_F");
expect(box!.displayName).toBe("Ammo Crate");
expect(box!.location).toEqual({ x: 100, y: 200, z: 0 });
expect(box!.server).toBe(serverId);
expect(box!.status).toBe("active");
expect(box!.sessionId).toBe(`${RUN}-reg`);
expect(box!.sourceEvent).toBe(rawId);
expect(box!.lastSyncedAt).toBeTruthy();
});
it("updates contents and lastSyncedAt from a box.sync event", async () => {
const boxId = `box:${RUN}-sync:1`;
await runBoxEvent(`${RUN}-evt-sync-reg`, "box.register", [boxId, "Box_NATO_Wps_F", "Ammo Crate", 1, 2, 3]);
const before = await fetchBox(boxId);
expect(before).not.toBeNull();
await runBoxEvent(`${RUN}-evt-sync`, "box.sync", [
boxId,
[
["Item_1", 10],
["Item_2", 5],
],
]);
const after = await fetchBox(boxId);
expect(after).not.toBeNull();
expect(after!.contents).toEqual([
{ class: "Item_1", count: 10 },
{ class: "Item_2", count: 5 },
]);
expect(new Date(after!.lastSyncedAt).getTime()).toBeGreaterThanOrEqual(
new Date(before!.lastSyncedAt).getTime(),
);
});
it("is idempotent when the same box event is applied twice", async () => {
const boxId = `box:${RUN}-idem:1`;
const payloadArgs = [boxId, "Box_NATO_Wps_F", "Ammo Crate", 1, 2, 3] as unknown[];
await runBoxEvent(`${RUN}-evt-idem-a`, "box.register", payloadArgs);
await runBoxEvent(`${RUN}-evt-idem-b`, "box.register", payloadArgs);
const found = await payload.find({
collection: "supply-boxes",
where: { boxId: { equals: boxId } },
limit: 10,
depth: 0,
overrideAccess: true,
});
expect(found.docs).toHaveLength(1);
for (const doc of found.docs) boxIds.push(doc.id);
});
it("no-ops on malformed payloads without throwing", async () => {
await runBoxEvent(`${RUN}-evt-malformed-1`, "box.sync", "not-an-array" as unknown as unknown[]);
await runBoxEvent(`${RUN}-evt-malformed-2`, "box.register", []);
await runBoxEvent(`${RUN}-evt-malformed-3`, "box.register", ["not-a-box-id", "Box_NATO_Wps_F", "X", 1, 2, 3]);
const found = await payload.find({
collection: "supply-boxes",
where: { boxId: { like: `${RUN}-malformed` } },
limit: 10,
depth: 0,
overrideAccess: true,
});
expect(found.docs).toHaveLength(0);
});
it("marks a box deleted and reactivates it on a later sync", async () => {
const boxId = `box:${RUN}-del:1`;
await runBoxEvent(`${RUN}-evt-del-reg`, "box.register", [boxId, "Box_NATO_Wps_F", "Ammo Crate", 1, 2, 3]);
await runBoxEvent(`${RUN}-evt-del`, "box.deleted", [boxId]);
const deleted = await fetchBox(boxId);
expect(deleted).not.toBeNull();
expect(deleted!.status).toBe("deleted");
await runBoxEvent(`${RUN}-evt-del-sync`, "box.sync", [boxId, [["Item_1", 3]]]);
const reactivated = await fetchBox(boxId);
expect(reactivated).not.toBeNull();
expect(reactivated!.status).toBe("active");
expect(reactivated!.contents).toEqual([{ class: "Item_1", count: 3 }]);
});
it("no-ops when the raw event has no server", async () => {
const rawId = await emitRaw(`${RUN}-evt-null-server`, "fixture", []);
await processBoxEvent(payload, {
id: rawId,
eventId: `${RUN}-evt-null-server`,
server: null,
type: "box.register",
occurredAt: new Date().toISOString(),
payload: [`box:${RUN}-null:1`, "Box_NATO_Wps_F", "X", 1, 2, 3],
});
const found = await payload.find({
collection: "supply-boxes",
where: { boxId: { equals: `box:${RUN}-null:1` } },
limit: 1,
depth: 0,
overrideAccess: true,
});
expect(found.docs).toHaveLength(0);
});
it("extracts the session id from the box id", () => {
expect(sessionIdFromBoxId("box:Altis-20260921120000:42")).toBe("Altis-20260921120000");
expect(sessionIdFromBoxId("box:Altis:1")).toBe("Altis");
expect(sessionIdFromBoxId("not-a-box-id")).toBeNull();
expect(sessionIdFromBoxId("box:Altis-1:abc")).toBeNull();
expect(sessionIdFromBoxId("box::1")).toBeNull();
});
it("wires crate sync through the afterChange hook", async () => {
const boxId = `box:${RUN}-hook:1`;
const raw = await payload.create({
collection: "arma-sync-events",
data: {
eventId: `${RUN}-evt-hook`,
server: serverId!,
type: "box.register",
occurredAt: new Date().toISOString(),
payload: [boxId, "Box_NATO_Wps_F", "Hook Crate", 10, 20, 30],
},
overrideAccess: true,
depth: 0,
});
syncEventIds.push(raw.id);
const opEvents = await payload.find({
collection: "operation-events",
where: { sourceEvent: { equals: raw.id } },
limit: 1,
depth: 0,
overrideAccess: true,
});
for (const doc of opEvents.docs) opEventIds.push(doc.id);
const box = await fetchBox(boxId);
expect(box).not.toBeNull();
expect(box!.boxClass).toBe("Box_NATO_Wps_F");
expect(box!.sourceEvent).toBe(raw.id);
});
});