7.5 KiB
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 existingflush_eventsingestion (POST /api/arma/v1/events). - Commands (web to mod): the
crate.*command types, delivered through the existingpull_commands/ack_commandloop.
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, andbackpackCargomerged by class into[itemClass, count]pairs. - Nested container contents are a deferred v1 extension. There is NO
containerIndexfield and no per-container breakdown; anything inside a nested container is invisible to this sync.
Box identity
- Box id format:
box:<sessionId>:<seq>. sessionIdis minted infn_serverInit.sqfasformat ["%1-%2", worldName, systemTime joinString ""], unique per mission load. The event sequence and queue are reset in the same place, soseqis 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}insrc/app/api/arma/v1/events/route.ts, which storesserver: server.id(thegame-serversrow) 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.sqfsleeps 15 seconds). - Each sync is a FULL snapshot per box:
box.syncreplaces 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, setlastSyncedAt = now. box.deletedRETAINS the row: it marks the rowstatus: deletedand keeps both the row and the rawarma-sync-eventsevidence for provenance. It never hard-deletes. A laterbox.registerorbox.syncfor the same boxId reactivates the row.- Null-server guard: if the authenticated ingestion context yields a null server (
raw.servernull), the event is malformed and is a NO-OP with a logged warning. TheSupplyBoxes.serverrelationship is REQUIRED and never receives null; no SupplyBoxes write happens on the malformed path. - Provenance: every SupplyBoxes row links to the raw
arma-sync-eventsrows that produced it (byeventId), 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 whatfn_pollCommandspasses and whatfn_executeCommand.sqfdestructures:_command params ["_id", "_type", ["_payload", []]]. Commands are stored in thearma-commandscollection (commandIdunique,serverrequired relationship,payloadJSON,statusqueued/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_commandwith[commandId, success, result, error](the shapefn_executeCommand.sqfalready uses). Successful acks carrysuccess: trueand an emptyresultarray. Failed acks carrysuccess: falseand a human-readable string in theerrorslot. The web side records the outcome throughacknowledgeCommand(src/lib/arma-bridge/commands.ts), which setsstatus: succeededorstatus: failedwith the result and error.
Failure rules
- Unknown
boxIdoncrate.apply: the mod acks the command as a FAILURE with an error string; the command is markedfailedweb-side. It is not retried by the mod. - Partial sync never blocks other boxes: a failure while processing one box's
register/sync/deletedevent 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 byrecoverExpiredCommands.
Security
- All channels sit behind
requireArmaServer(ARMA_BRIDGE_API_KEYplus thex-arma-server-idheader). 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_boxRegistryholding[boxId, objectHandle]pairs for every tracked box. - Deletion detection: ONLY via
isNullchecks against registry entries. The mod never rescans the world to discover deletions. A registry entry whose handle has become null produces abox.deletedevent 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.sqfdecides which objects are tracked. It checksReammoBox_F,WeaponHolder, andTransport*config checks. It has NO dependency onptf_loot; the predicate is intentionally duplicated so the bridge addon stays independent.