Burnsies HideoutDocsBurnsie.Common

Backup Dispatcher — BackupDispatcher

Wave-based backup orchestration with honest unit tracking: the dispatcher knows which vehicles are actually yours, when they exist, and when they arrive on scene. It exists because the naive approaches both fail:

  • Functions.RequestBackup alone gives you a vehicle handle but no arrival signal, and spawns can silently fail.
  • Events.OnBackupUnitCreated fires for all backup in the game — including the player's own backup menu and other plugins. Blindly adopting every event would hijack units that aren't yours.

BackupDispatcher does both and correlates them: every RequestBackup return value is kept, and unit-created events are matched by request position (30 m) and time window (45 s). Duplicates (a vehicle seen via both paths) are folded together.

Core model

TypeRole
WaveA labeled batch of units with a delay after the previous wave
UnitOrderOne unit in a wave: EBackupUnitType + EBackupResponseType (default Code3)
DispatchOptionsSession knobs: position provider, escalation gate, blips, stagger, arrival radius
TrackedUnitA followed unit: Vehicle, Blip, Type, CreatedUtc, HasArrived

Usage

var dispatcher = new BackupDispatcher(log);
dispatcher.UnitTracked += u => log.Info("tracking " + u.Type);
dispatcher.UnitArrived += u => notifier.Show(Palette.Police(u.Type.ToString()) + " on scene");

// 1. Open a session (one per incident):
dispatcher.BeginSession(fibers, dutyFlag, new DispatchOptions
{
    PositionProvider   = () => Game.LocalPlayer.Character.Position,  // re-read live
    ContinueEscalation = () => StillInDanger(),   // gate between waves; false = stop escalating
    AttachBlips        = true,                    // police light blue; EMS white; fire orange
    ArrivalRadius      = 35f,
    StaggerMs          = 300,                     // between unit requests (real time)
    SpreadRadius       = 18f,                     // 0 = off; see "Spread response" below
});

// 2. Build the timeline:
var waves = new List<Wave>
{
    new Wave { Label = "patrol", DelaySeconds = 0,
               Units = { new UnitOrder { Type = EBackupUnitType.LocalUnit },
                         new UnitOrder { Type = EBackupUnitType.LocalUnit } } },
    new Wave { Label = "tactical", DelaySeconds = 45,
               Units = { new UnitOrder { Type = EBackupUnitType.SwatTeam } } },
};

// 3. Run it (blocks the calling fiber for the whole timeline — run on your own fiber):
int created = dispatcher.DispatchWaves(waves);    // honest count of spawned units

// 4. Stand down:
dispatcher.CancelDispatch();          // synchronous, never yields — safe as the FIRST
                                      // step of a stand-down, kills the timeline instantly
dispatcher.EndSession(fadeBlips: true);

Live state while the session runs: TrackedCount, ArrivedCount, TotalTrackedCount (cumulative, never decremented) and Snapshot() (safe to iterate; validate entities before use).

Semantics that matter

Incident-scoped cancellation

BeginSession creates an internal incident flag alongside your duty-wide flag. CancelDispatch()/EndSession cancel the incident: the wave timeline, event adoption and tracker fiber all stop — without touching your duty-wide flag or other services. A sleeping timeline observes the flag it was born with, so a rapid EndSessionBeginSession cannot resurrect the old timeline into the new incident ("zombie resume").

Spread response (SpreadRadius)

With a spread radius set, successive unit requests target golden-angle bearings on rings around the incident position instead of the exact same point, so a big response surrounds the scene rather than stacking into a wall of vehicles on one road node (which physically blocks late arrivals — the classic "ambulance can't reach the downed officer" jam). LSPDFR snaps targets to road nodes, so offsets only diversify approaches. Keep the spread smaller than ArrivalRadius or arrival detection will undercount. Correlation uses the offset positions consistently.

Unit blip colors

With AttachBlips, tracked units get community-standard colors automatically: police light blue, ambulances white (named "EMS"), firetrucks orange — so medical units read at a glance on a busy minimap.

Escalation gating

Between waves (never before the first), ContinueEscalation() is consulted; returning false skips all remaining waves — the threat is cleared, stop sending armies. A throwing gate is treated as true: when in doubt, keep help coming.

Arrival detection

The tracker checks distance to the live incident position every 500 ms. Ground units arrive within ArrivalRadius (3D). Air units are special-cased: helicopters hold altitude and orbit, so a 3D check never passes — they are judged by ground-plane (2D) distance with a widened ring (≥ 80 m), i.e. "on scene" while overhead.

Ownership

Tracked vehicles remain LSPDFR-owned. The dispatcher never makes them persistent and never deletes them; EndSession removes its blips and forgets the units, letting LSPDFR's own cleanup work. Do not put dispatcher units in your EntityBag.

Blip fade on stand-down

EndSession(fadeBlips: true) fades unit blips out (~1.6 s) on a detached fiber, deliberately not a registry fiber: teardown paths often run on LSPDFR's callout fiber (must not block) and registries are typically about to AbortAll (which would kill the fade mid-way and leak the blips).

Agency override

DispatchOptions.AgencyScriptName forces a specific agency; leave it null/empty to let LSPDFR and backup.xml decide — which is what respects users' region packs. When overriding, ExactLocation and NoResponseTask are passed through to LSPDFR.

Rules

  • DispatchWaves and EndSession must run on a GameFiber.
  • PositionProvider is called from fibers repeatedly — keep it cheap and side-effect free.
  • Event handlers (UnitTracked, UnitArrived) fire on dispatcher fibers: return fast, hand real work off. See the Crowd engine's EscalationLadder for a full consumer that re-tasks arriving units.
  • One session per incident: BeginSession implicitly ends any previous session.