Koja Scripts

API overview

Concepts, lifecycles, the call router, data formats, return conventions, error codes and versioning shared by every koja-dispatcher export, hook and event.

API overview

koja-dispatcher exposes five integration surfaces:

SurfaceSideUse it to
Server exportsserverRing calls, create and manage incidents, move units, read dispatchers, change and register settings, create BOLOs, log to Discord, report plates.
Client exportsclientCheck whether the local player is on shift, raise an alert or a /911 call from a client script.
HooksserverBlock or rewrite calls, incidents, assignments and shifts before they happen, and react after.
EventsbothListen to dispatcher activity with plain AddEventHandler, feed phones and other dispatch scripts in.
SettingsserverEvery key GetSetting and SetSetting accept, with ranges and defaults.

The examples page has complete, copy-paste recipes.

Getting started

  1. Start order

    Start your resource after koja-dispatcher (and its dependencies oxmysql and koja-lib).
    server.cfg
    ensure oxmysql
    ensure koja-lib
    ensure koja-dispatcher
    ensure my-robberies
  2. Call an export

    The resource folder must be named koja-dispatcher: the folder name is the export namespace.
    server.lua
    local dispatch = exports['koja-dispatcher']
    print(('%d dispatcher(s) on shift'):format(dispatch:GetDispatcherCount()))
  3. Handle the result

    Exports that create something return id, route on success and nil, errorCode on failure. Always check the first value.
    server.lua
    local id, route = exports['koja-dispatcher']:CreateCall({
        coords = vector3(24.5, -1347.3, 29.5),
        caller = 'Store alarm',
        message = 'Silent alarm at the 24/7 on Innocence Blvd',
    })
    if not id then
        print('911 call failed: ' .. tostring(route)) -- route holds the error code
    end

Concepts

ThingWhat it is
CallA line at the dispatcher console. kind = 'script' is a generated caller with a scripted (or AI) conversation; kind = 'player' is a text line from /911, a phone, an export, a dispatch-script alert or koja-mdt. A dispatcher answers the call, questions the caller and dispatches it, which turns it into an incident.
IncidentA job on the map. It has a category, priority 1 to 3, a need (units per department), progress, danger, assigned units and units on scene. It ends as resolved, failed or cancelled.
UnitAn AI unit from the fleet (ai:<n>) or a player on duty in a department (p:<serverId>). Departments are police, ems and fire.
DispatcherA player on shift at a station console, or with the remote CAD (/cad) when access.remote is on.

Call lifecycle

StateMeaningNext
ringingWaiting at the console. Generated calls hang up after calls.patience seconds (callMissed).talking
talkingA dispatcher answered (callAnswered). Questions, instructions and replies happen here.done
doneDispatched (the call's incident field holds the new incident id), hung up, marked as a prank or expired (callEnded). Removed 4 s later.

Player calls also end on their own when nobody touched them for 3 times calls.playerReplyWindow. Within calls.playerReplyWindow a second /911 from the same player is added to the open call instead of creating a new one.

Call ids start at 1 and count up per server start.

Incident lifecycle

StatusMeaning
activeOn the map. Units drive to it; when they are on scene, progress fills at a speed set by how much of the need is covered. Uncovered time raises danger (exposure). Scenarios can escalate (more units, priority 1) when danger passes a threshold.
resolvedProgress reached 100 %, a field unit sent code 4, ResolveIncident was called, the MDT call was closed (outcome = 'mdt') or a prank was uncovered (outcome = 'prank'). Multi-stage scenarios start the next stage instead of resolving.
failedDanger reached the incident's fail time, or a pursuit suspect got away (outcome = 'lost').
cancelledA dispatcher or CancelIncident cancelled it (outcome = 'cancelled', or 'prank' for a prank call).

Ended incidents stay in GetIncidents() for 20 seconds (Config.Incidents.lifetimeAfterEnd), then disappear. Incident ids start at 1001.

An incident whose caller did not give the location yet is located = false: it sits at an approximate point (up to Config.Calls.approximateRadius = 300 m off). Units that arrive there first search (searching) before they find the scene.

Incidents created from a player (/911 fallback, follow field) follow that player until the first unit is on scene.

Unit lifecycle and statuses

StatusMeaning
availableFree. Can be assigned.
enrouteDriving to an incident or to a staging point.
searchingArrived at an approximate location, looking for the scene.
onsceneWorking the incident.
transportEMS unit driving a patient to a hospital.
returningAI unit driving back to its station. Can be assigned again.
busySet by hand (CAD, field code 10-6, SetUnitStatus). Not assignable.
unavailableSet by hand (field code 10-7, SetUnitStatus). Not assignable.
offlineAI unit off duty: mode.units = 'players', or enough players of its department are on duty (units.disableWhenPlayers).
resting / repairAI unit resting or in repair at its station (fleet module, fleet.enabled).

AI units follow the road graph; air units fly straight. Player units are real players and their position is read from their ped.

Unit purposes (the purpose field): incident, move (staging point), home, hospital, handover, patrol.

Dispatcher lifecycle

A dispatcher starts a shift at a console (shiftStart hook, then shiftStarted), works calls and incidents, and ends it (shiftEnded) with a summary: minutes, stats, a 1 to 5 star rating and pay. Leaving the server ends the shift without pay. A player on shift is never a field unit at the same time.

The call router

CreateCall, /911, phone calls, the client SendAlert export and dispatch-script alerts in call mode all go through one router:

StepCheckResultReturned
1At least one dispatcher is on shift and mode.calls is not generatedThe call rings at the console (callCreate, callCreated).callId, 'call'
2Otherwise, mode.noDispatcher = 'auto'An incident is created at once and the nearest free units are auto-assigned. The caller gets a "units dispatched" notification.incidentId, 'incident'
3Otherwise, mode.noDispatcher = 'mdt', koja-mdt is running and the call did not come from koja-mdtThe call is created as a koja-mdt dispatch call.mdtCallId, 'mdt'
4Anything else (reject, or the MDT is down)Nothing is created.nil, 'no_dispatchers'

If a callCreate hook blocks the call in step 1, the router returns nil and the hook's reason (cancelled when the hook gave none). If an incidentCreate hook blocks the incident in step 2, the router returns nil, 'cancelled'.

Admins switch modes in game with /dispatchadmin. Defaults live in Config.Modes (calls = 'mixed', units = 'mixed', noDispatcher = 'reject').

Data formats

Coordinates

Every coords parameter accepts:

FormExample
vector3 / vector4vector3(24.5, -1347.3, 29.5)
table with keys{ x = 24.5, y = -1347.3, z = 29.5 }
table without z{ x = 24.5, y = -1347.3 } (z becomes 0.0)
array{ 24.5, -1347.3, 29.5 }

x and y must be within ±9000, otherwise the export returns nil, 'invalid'. CreateCall and CreateIncident also take source = serverId instead of coords to use that player's position.

Needs

need is how many units of each department an incident requires:

need = { police = 2, ems = 1, fire = 0 }

Only police, ems and fire are read. Values are floored and capped at 8 per department; zero and negative values are dropped. An incident with an empty need gets { police = 1 }. Unit types have a power (Config.UnitTypes): SWAT and ladder trucks count as 2.

An incident has two needs: need (the truth) and known (what the dispatcher knows). Exports set both to the same value; generated calls reveal the rest through questions or on scene.

Categories

robbery, burglary, assault, shooting, domestic, disturbance, suspicious, medical, overdose, fire, traffic, rescue, hostage, pursuit, officer_down, hazmat.

The list is Scenarios.Categories in shared/scenarios.lua. An unknown category is ignored by CreateCall and UpdateIncident and becomes suspicious in CreateIncident.

Priority

1 (highest) to 3. Values are clamped. Priority 1 incidents are announced as alerts on the radio.

Unit references

Unit exports take a unit reference:

ReferenceMeaning
'ai:3'AI unit 3 of Config.AiUnits.fleet (the index in the list).
'ai:d12'AI unit with database row 12, when the fleet was edited in the in-game fleet editor.
'p:12'The player unit of server id 12.
12 (number)The same as 'p:12': the player unit of that server id.

Unit ids depend on the fleet configuration, so look them up with GetUnits() (for example by callsign) instead of hard-coding them.

Return conventions

Export kindSuccessFailure
Create (CreateCall, CompatAlert, PhoneCall, client SendAlert)id, routenil, errorCode (client exports: false, errorCode)
CreateIncident, CreateBolothe idnil, errorCode
CloseBolo, SetSetting, RunFieldCodetruefalse, errorCode
Actions (UpdateIncident, ResolveIncident, ReleaseUnit, SetCallsign, RegisterSetting, ...)truefalse
Counters (AssignUnits, AutoAssign)number of units assigned (0 when nothing happened)0
Lookups (GetCall, GetUnit, ...)a tablenil
Lists (GetUnits, GetIncidents, ...)an arrayan empty array

Returned tables are copies: changing them does not change the dispatcher. Exports never throw on bad input.

route is 'call' (rings at the console), 'incident' (created as an incident), 'mdt' (sent to koja-mdt; the id is the MDT call id) or, for player calls added to an open line, 'update'.

Error codes

CodeReturned byMeaning
invalidmost exportsMissing or malformed input (no coordinates, unknown scenario, bad key, message too short).
no_dispatcherscall routerNobody on shift and mode.noDispatcher did not create anything.
cancelledcall router, CreateIncident, CompatAlert, client SendAlert, shift startA before hook blocked the call, incident or shift. A hook can return its own code instead (return false, 'suspended'); that code is passed on in place of cancelled.
disabledSendAlert, PhoneCall, CompatAlert, RunFieldCodeThe feature or setting is off (api.clientAlerts, phones.enabled, compat.enabled, mdt_status.enabled).
no_responseclient exportsThe server did not answer within 10 s, or the request hit the rate limit.
ratePhoneCallThe same player called again within Config.Phones.cooldown (2 s).
duplicateCompatAlertAn identical alert came in within Config.Compat.dedupeSeconds (20 s).
not_field_unitRunFieldCodeThe player is not a field unit or MDT officer on duty, or is a dispatcher.
cooldownRunFieldCodeBackup or panic used again within mdt_status.cooldown.
offline / busy / unavailable / no_channelConnectVoiceVoice call cannot start: a player left, one of them is already in a voice call, no voice resource, or all channels are used.
title_required / plate_required / invalid_plate / invalid_url / not_found / mdt_offline / mdt_failed / dbCreateBolo, CloseBoloBOLO input or storage problem, see CreateBolo.

Hooks and events

Every lifecycle point is published twice:

  1. As a hook: RegisterHook(name, fn). before hooks (callCreate, incidentCreate, unitAssign, shiftStart) run first and can block the action (with an optional reason) or, for incidentCreate, change it. after hooks run when it happened.
  2. As a server-local event: koja-dispatcher:<name> for every after hook, fired right after the hook functions, with the same payload. Listen with AddEventHandler.

Use a hook when you need to block or rewrite something, or want automatic cleanup when your resource stops. Use the event for simple listeners. See Hooks and Events.

Server and client rules

TopicRule
SideServer exports can only be called from server scripts. Client exports talk to the server through a rate-limited RPC.
TrustServer resources are trusted: server exports do not check permissions. Validate player input (distance, job, cooldown) in your own net events before you call them.
YieldingServer exports do not yield, except the ones that write the database (SetSetting, CreateBolo, CloseBolo) or talk to koja-mdt. Client exports SendAlert and Call911 yield until the server answers; call them from a thread or an event handler.
Rate limitsClient RPCs are limited per player (Config.Security.limits). SendAlert: burst 3, then one every 5 s. Call911: burst 2, then one every 20 s.

Own code inside the resource

If you need to change koja-dispatcher itself, put your own files in server/custom/*.lua and client/custom/*.lua. Both folders are loaded automatically, after all built-in modules, so KOJA.Server.* / KOJA.Client.* are ready when your file runs. Do not add files to server/features/: the manifest lists those files one by one, so a new file there is not loaded. Keep a copy of your custom files when you update.

Versioning

ItemValue
Resource / export namespacekoja-dispatcher
Versionversion in fxmanifest.lua (1.1.0 at the time of writing)
Product keykoja_resource 'koja-dispatch' in fxmanifest.lua, used by koja-lib to look up the latest release
Database tableskoja_dispatch_*

On start the server prints a banner and, when Config.VersionCheck.enabled is on and koja-lib is started, asks koja-lib for the release status: up to date, development build, update available or critical update (with the first changelog lines and a download link).

Feature-detect from your own resource instead of assuming a version:

server.lua
local state = GetResourceState('koja-dispatcher')
local version = GetResourceMetadata('koja-dispatcher', 'version', 0)
if state == 'started' then
    print(('koja-dispatcher %s is running'):format(version))
end

-- an export that may not exist in older versions
local ok, id = pcall(function() return exports['koja-dispatcher']:CreateBolo(data) end)

/dispatchcheck (server console or admins) runs a health check: koja-lib, oxmysql, framework, database tables, dispatcher jobs, AI fleet, web build, AI keys, settings, stations and the koja-mdt link.