feat(operations): supply-box sync store and handler
This commit is contained in:
parent
42066e9d13
commit
f2bd5418b2
9 changed files with 32168 additions and 1 deletions
|
|
@ -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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
|
||||||
73
src/collections/server/SupplyBoxes.ts
Normal file
73
src/collections/server/SupplyBoxes.ts
Normal 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.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
209
src/lib/arma-bridge/crates.ts
Normal file
209
src/lib/arma-bridge/crates.ts
Normal 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"}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
31460
src/migrations/20260921_113443_add_supply_boxes.json
Normal file
31460
src/migrations/20260921_113443_add_supply_boxes.json
Normal file
File diff suppressed because it is too large
Load diff
53
src/migrations/20260921_113443_add_supply_boxes.ts
Normal file
53
src/migrations/20260921_113443_add_supply_boxes.ts
Normal 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";`)
|
||||||
|
}
|
||||||
|
|
@ -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'
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
| {
|
| {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
259
tests/int/supply-box-sync.int.spec.ts
Normal file
259
tests/int/supply-box-sync.int.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue