docs(arma-bridge): add crate-sync contract
This commit is contained in:
parent
f2bd5418b2
commit
1f7d1e47ef
1 changed files with 82 additions and 0 deletions
82
docs/arma-bridge/crate-sync.md
Normal file
82
docs/arma-bridge/crate-sync.md
Normal file
|
|
@ -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>:<seq>`.
|
||||||
|
- `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.
|
||||||
Loading…
Reference in a new issue