diff --git a/docs/arma-bridge/crate-sync.md b/docs/arma-bridge/crate-sync.md new file mode 100644 index 0000000..bf6a32b --- /dev/null +++ b/docs/arma-bridge/crate-sync.md @@ -0,0 +1,82 @@ +# Crate Sync Contract + +Binding contract for supply-crate (box) sync between the Arma 3 bridge mod and the web app. Todos 5 (web) and 6 (mod) implement exactly what is written here; do not redesign it while implementing. + +## Overview + +Supply crates spawned in-game need to appear on the web, and web-side crate definitions need to spawn in-game. This document fixes the two channels that carry that data: + +- **Events (mod to web)**: the `box.*` event family, delivered through the existing `flush_events` ingestion (`POST /api/arma/v1/events`). +- **Commands (web to mod)**: the `crate.*` command types, delivered through the existing `pull_commands` / `ack_command` loop. + +Crates are deliberately OUT of the operation ledger: `operation-events` validation rejects array payloads for `operation.*` types, and crates are not operations. The web events route accepts any `type` string, so the `box.*` family flows through existing ingestion unchanged, array payloads included. + +The bridge extension DLL is closed: only `heartbeat`, `flush_events`, `pull_commands`, `ack_command`, and `fetch_tickets` channels exist. Crate sync adds no new extension channels; it rides the event flush and the command poll. + +## Event family + +All payloads are flat arrays. `fn_queueEvent.sqf` stores queue entries as `[_id, _type, _payload, serverTime]` and defaults `_payload` to `[]`, so every event below is an array of positional values. + +| Type | Payload | Meaning | +|---|---|---| +| `box.register` | `[boxId, boxClass, displayName, locationX, locationY, locationZ]` | A tracked box object exists at a position. | +| `box.sync` | `[boxId, [[itemClass, count], ...]]` | Full cargo snapshot for one box. | +| `box.deleted` | `[boxId]` | The box object no longer exists. | + +`box.sync` cargo rules: + +- TOP-LEVEL cargo only: `itemCargo`, `magazineCargo`, `weaponCargo`, and `backpackCargo` merged by class into `[itemClass, count]` pairs. +- Nested container contents are a deferred v1 extension. There is NO `containerIndex` field and no per-container breakdown; anything inside a nested container is invisible to this sync. + +## Box identity + +- Box id format: `box::`. +- `sessionId` is minted in `fn_serverInit.sqf` as `format ["%1-%2", worldName, systemTime joinString ""]`, unique per mission load. The event sequence and queue are reset in the same place, so `seq` is a missionNamespace counter that starts at 0 each mission load. +- The mod NEVER embeds a server identifier in the box id. No server identifier exists mod-side. The WEB resolves the owning server authoritatively from the authenticated ingestion connection, the same pattern as `eventId = ${server.serverId}:${event.id}` in `src/app/api/arma/v1/events/route.ts`, which stores `server: server.id` (the `game-servers` row) on every raw event. +- A mission restart mints a new generation (new sessionId). Rows from older sessionIds remain in the database as stale snapshots; they are not deleted. The UI filters by the newest generation per server. + +## Sync cadence + +- Boxes are scanned and synced every 2nd bridge cycle, roughly every 30 seconds (the bridge cycle loop in `fn_serverInit.sqf` sleeps 15 seconds). +- Each sync is a FULL snapshot per box: `box.sync` replaces the box's cargo wholesale, it is never a delta. +- Dedup is server-side by `eventId` (the events route does a batched existence lookup and also survives unique-constraint races), so a retried flush cannot double-apply. +- Per-cycle candidate cap: 64 boxes per scan cycle. The cap bounds event volume; boxes beyond the cap wait for the next cycle. + +## Web-side consumption rules + +- Upsert by `boxId`: create or update the SupplyBoxes row, set `lastSyncedAt = now`. +- `box.deleted` RETAINS the row: it marks the row `status: deleted` and keeps both the row and the raw `arma-sync-events` evidence for provenance. It never hard-deletes. A later `box.register` or `box.sync` for the same boxId reactivates the row. +- Null-server guard: if the authenticated ingestion context yields a null server (`raw.server` null), the event is malformed and is a NO-OP with a logged warning. The `SupplyBoxes.server` relationship is REQUIRED and never receives null; no SupplyBoxes write happens on the malformed path. +- Provenance: every SupplyBoxes row links to the raw `arma-sync-events` rows that produced it (by `eventId`), so any web-side state can be traced back to the exact mod event. + +## Command envelope and schemas + +- Envelope: `[commandId, commandType, payload]`. This is exactly what `fn_pollCommands` passes and what `fn_executeCommand.sqf` destructures: `_command params ["_id", "_type", ["_payload", []]]`. Commands are stored in the `arma-commands` collection (`commandId` unique, `server` required relationship, `payload` JSON, `status` queued/delivered/succeeded/failed). +- Crate arguments live INSIDE `payload`, also as arrays: + +| Type | Payload arguments | Effect | +|---|---|---| +| `crate.spawn` | `[boxId, boxClass, x, y, z]` | Create the box object at the position and register it. | +| `crate.apply` | `[boxId, [[itemClass, count], ...]]` | Replace the box's top-level cargo with the given items. | +| `crate.despawn` | `[boxId]` | Delete the box object and remove it from the registry. | + +- Expected ack results: every command is acked exactly once via `ptf_bridge.ack_command` with `[commandId, success, result, error]` (the shape `fn_executeCommand.sqf` already uses). Successful acks carry `success: true` and an empty `result` array. Failed acks carry `success: false` and a human-readable string in the `error` slot. The web side records the outcome through `acknowledgeCommand` (`src/lib/arma-bridge/commands.ts`), which sets `status: succeeded` or `status: failed` with the result and error. + +## Failure rules + +- Unknown `boxId` on `crate.apply`: the mod acks the command as a FAILURE with an error string; the command is marked `failed` web-side. It is not retried by the mod. +- Partial sync never blocks other boxes: a failure while processing one box's `register`/`sync`/`deleted` event does not abort the remaining boxes. Events are independent rows; one bad box costs only its own update. +- Duplicate eventIds are deduped server-side (counted as duplicates, no double-apply), so mod-side retry of a flush is safe. +- Ack guards (existing behavior in `acknowledgeCommand`): duplicate acks are no-ops (the first result wins), acking a command that was never delivered is rejected (`not-delivered`), and a delivered command whose 5-minute lease expires without an ack is re-queued by `recoverExpiredCommands`. + +## Security + +- All channels sit behind `requireArmaServer` (`ARMA_BRIDGE_API_KEY` plus the `x-arma-server-id` header). This covers event ingestion and command pull/ack alike. +- The owning server is always derived from the authenticated connection, never from payload content. Box ids and command payloads carry no server identity and are never trusted to do so. + +## Mod-side behavior + +- Registry: a missionNamespace variable `ptf_boxRegistry` holding `[boxId, objectHandle]` pairs for every tracked box. +- Deletion detection: ONLY via `isNull` checks against registry entries. The mod never rescans the world to discover deletions. A registry entry whose handle has become null produces a `box.deleted` event and is removed from the registry. +- Replacement objects: an object that replaces a deleted box (new handle) is a NEW boxId with a new sequence number. Box ids are never reused. +- Container predicate: a locally duplicated `fn_isBoxContainer.sqf` decides which objects are tracked. It checks `ReammoBox_F`, `WeaponHolder`, and `Transport*` config checks. It has NO dependency on `ptf_loot`; the predicate is intentionally duplicated so the bridge addon stays independent.