Overview
Two officers and a firefighter sign on at nine on a Tuesday. There are no civilians. Nothing happens, so after twenty minutes somebody does donuts in the parking lot and logs off.
This resource is the fix for that evening. It invents incidents, pages every available unit of the right job at the same moment, and then gets out of the way: whoever shows up works the scene, and the scene reacts to what they actually do.
Three things make it different from a callout script:
- Nobody is assigned a call. A new incident reaches everyone on duty in that service at once, with a card in the panel, a blip and — if you run the radio — a page. Taking it never closes it to anyone else, and a call nobody takes keeps paging instead of quietly vanishing.
- One scene, worked together. Any number of units attach to the same incident and share one fact board. A plate that comes back stolen, a warrant, a victim in the back bedroom — the instant one unit finds it, the whole crew has it, with that unit's name against it.
- Nothing is on rails. There are no timelines and no beat lists. Every person in a scene carries compliance, flight, violence and honesty values that move. Shout at somebody and compliance drops. Leave them standing alone and flight climbs. Put two units on scene and talk calmly and they settle. The same callout twice is not the same evening.
Twenty-four callouts ship — twenty of them page police, eleven page fire, and four page both as one scene — across twelve location sets. Some of them are calls where there is nothing there, scored as having been handled properly, because that is a real evening too.
It works standalone. It works better next to Sal's Kewl Dispatch, which turns a new incident into a tone-out on the fire pager and a page on the talkgroup, and lets a unit go on scene over the radio.
This is a 0.0.x build. Versions stay 0.0.x
until the first public release. Everything this manual describes is behavior
that ships; where something is deliberately not modeled yet, it says so.
Requirements
| Server artifact | FiveM build 5848 or newer. The resource uses state bags with a replication filter, which is the one modern-artifact feature it depends on. |
|---|---|
| Framework | qb-core, qbx, ESX, or none. Standalone is supported rather than tolerated: with no framework it falls back to license identifiers and a civilian job, and you make somebody a responder with a state bag (see Your first ten minutes). |
Everything else is optional, detected at runtime with
GetResourceState, and not in the manifest's
dependencies. Nothing below is required and the resource is
complete without any of it.
sals_kewldispatch | Pager tone-outs for fire and EMS, talkgroup pages, and radio status in both directions — a unit who says they are on scene over the radio arrives in the scene. Needs only the in-game radio; no subscription. With the AI package, calls arrive as 911 calls the AI takes. |
|---|---|
sals_kewljobnotify | Pages go through it when the dispatch resource is absent. |
sals_kewlevidence | Seized property goes into it. Kept in the scene record otherwise. |
wasabi_ambulance | Player medical state is left entirely to it. This resource models NPC patients only and has no path to a player's downed state. |
ox_target / qb-target | Used for interacting with the people in a scene when present. Without a target resource the on-screen prompt does the same job. |
ox_lib | Used for notifications when present. |
Installation
- Drop the folder into
resources/assals_kewlpublicsafetysimulator. The folder name matters: it is the resource name the exports and the NUI page are published under. - Add it to your
server.cfg:ensure sals_kewlpublicsafetysimulator - Start the server and open
/pssadminin game. - Tell it which of your jobs are police, fire and EMS. This is the one thing nobody can guess for you, and until it is right, pages reach nobody.
- Pick a preset, and change anything you like afterward.
That is the whole setup. There is no config file to edit, no SQL to import, and no permission to grant beyond your own admin.
Admin is the pss.admin ace, your framework's
own admin check, or the server console. If /pssadmin tells you
no, add the ace:
add_ace group.admin pss.admin allow
Your first ten minutes
1. Point it at your jobs
/pssadmin opens on setup. The jobs step lists the job names it
can actually see on your server — read out of qb-core's shared jobs, qbx,
or ESX — and you tick which ones are police, which are fire and which are
EMS. A server with police, sheriff and
statepolice ticks all three under police.
A service with no jobs ticked is a service that is off: its callouts never run. That is how you get a police-only server, and it is one of the shipped presets.
Standalone servers have no job list to read. Set the state bag instead, server side, wherever your own duty system lives:
Player(src).state:set('pssJob', { name = 'police', onDuty = true }, true)
2. Pick a preset
| Realistic | The default pace. A call every few minutes at most, with ceilings that keep a small department from drowning. |
|---|---|
| Busy city | More calls, more at once. For a server with ten units on. |
| Quiet rural | Long gaps, and a call means something when it comes. |
| Police only | Fire and EMS off. Nothing fire-shaped is ever generated. |
| Fire and EMS only | The other way round. |
| Training night | Short intervals and loose caps, for testing or for a drill. |
A preset sets the pace and the ceilings. It never touches your job names — those are yours, and a preset that cleared them would be a trap.
3. Wait, or open one yourself
The Director waits a random interval between IntervalMin and
IntervalMax (four to fifteen minutes by default), and then opens a
call if the ceilings allow it, there is at least one unit of the right
service on duty, and it can find a location that is not too close to anybody.
If you would rather not wait, open one from /pssadmin.
4. Check what it saw
/pssdiag prints the detected framework, the jobs it mapped, the
catalog it loaded, anything it refused and why, every setting you have changed
from the shipped default, and what the resource has actually done since it
started. It is the first thing to run when something looks wrong, and the first
thing to paste when asking for help.
How a call runs, start to finish
Worth reading once, because almost every question about the resource is really a question about one of these seven steps.
- The Director opens a call. It picks a callout by weight (nudged a little by priority), picks a location from that callout's location set, and checks the location is between 200 m and 2.5 km from the nearest unit of that service. A call nobody could be paged for is never opened.
- Everybody available is paged. Every on-duty player of the relevant job gets the card, the blip and the notification at the same moment. With the radio running, fire and EMS also get a tone-out and the talkgroup gets a page. Nothing exists in the world yet — the call is one table on the server.
- Somebody responds. They press Respond in the panel and become En Route. Anyone else can respond to the same call at any time, including after it has been running for ten minutes. An unanswered call re-pages every two minutes and expires quietly after fifteen.
- The scene stages. The first responder to come within 250 m is the one whose client builds the world: the people, the vehicles, the props, the fire. Before that moment there is no ped to walk off and no fire burning in an empty field.
- The crew works it. Mark yourself On scene and the verbs open up. The first unit on scene takes command; any unit can take it from them. Everything a unit does is a server request: the server checks you are on this scene, on scene rather than still driving, in range of what you are acting on, and that the scene allows it — and only then applies the effect. What comes back lands on the fact board for everybody.
- The scene reacts. Rules run once a second and also the instant any fact changes, which is what makes a scene feel like it is paying attention. A fact discovered can escalate the call, open an objective, move somebody's disposition, page another service, or call an NPC unit.
- It closes. When the conditions for one of the callout's outcomes are met, that outcome wins and the scene closes with an after-action report: what was true, what was found and what was never found, who did what, and for a fire, time to water and time to knockdown. By default a call also ends when the last unit on it clears — the officer decides when they are done, not a timer.
Escalation raises the scene level, which re-pages the call. A traffic stop where the driver runs becomes a call every unit hears about again. That is correct behavior, and it is why a second page for a call you are already on is not a bug.
The panel
/pss, or F6. Four tabs.
Calls
At the top, a line saying whether this resource can see you on duty — shown even when everything is fine, because a quiet tab looks identical to a broken one. Then everything holding for your service, newest first, each with its priority badge, the line dispatch gave it, how old it is, how far away it is, and how many units are already responding. Respond attaches you. Set waypoint routes you there without attaching, for when you want to drive past and look first. Ping drops a flashing blip that fades on its own — the one to use when you are telling somebody else on the radio where to go.
Scene
The call you are on, and the only screen that matters while you work it. A bar across the top carries your own status and the two lookups — Run them and Run the plate — promoted out of the action list, because on a server with no CAD this resource is the lookup and hunting for it in a grouped list while sitting behind a car is the wrong shape for it.
- What we know — the fact board. Every fact the crew has discovered, in plain language, with who found it. This is the shared brain: it is written on the server, so it is the same for everybody within a moment of being found.
- Units — who is on the call and their status. A star marks whoever has command.
- Objectives — what the scene wants done. Some arrive with the call (knock down the fire); some open when something is discovered (somebody is unaccounted for — search it).
- Actions — what you can do right now, grouped by who or what it acts on. Each person in the scene gets their own heading, with the questions you can ask them at the top and their verbs underneath — which is the order a conversation and a search actually happen in. Everything else sits under the scene. The list is computed on the server for your service and your position, so a button you can see is a button that will work, and the questions change as answers land.
- Case file — the paperwork the crew has collected. Every ID card anybody on the call has looked at or run is kept here, for everybody on the scene: the fact board rule applied to documents.
- Call for — request an NPC service when no player is covering it, and see its ETA.
- Fire — on a fire call: how many seats are burning of how many, and whether the building is vented.
- Log — the scene's own timeline.
With ox_target or qb-target running, the third eye
on a person offers their verbs directly, plus Talk to them, which
opens the panel on that person with their questions in view. Without a target
resource the on-screen prompt does the same job.
On scene marks you arrived. Take command takes command. Clear detaches you, and by default the last unit clearing closes the call.
The pocket notebook
Separate from the panel, and the thing you will actually read while you work: a lined notebook in the corner that fills in as the case goes. What people said, the licence you were handed, what a search or a test turned up, what the rest of the crew found. It shows itself for a few seconds whenever a new line is written, and F7 pins it open or puts it away.
One book per call, shared by every unit on it. Verb results, statements and answers go in the book rather than into a notification that scrolls away — which is the difference between a scene you can follow and a scene you have to remember.
Report
The after-action report of the call that just closed: the outcome and whether it was well handled, what was true, what was found and by whom, what was never found at all, and who did what.
Setup
Admin only. The whole configuration, which is the next part of this manual.
What a police unit can do
First, talking to them
Talking is not a verb with a progress bar. It is a conversation: pick somebody and you get the questions that make sense right now, and the list grows as you learn things. “Why is it suspended?” does not exist until you know the licence is suspended.
Whether they answer honestly is their honesty and their compliance together, so a frightened honest person still says nothing. A lie is not final — the question stays there, so calming somebody down and asking again is the whole mechanic rather than a flavor text. Every answer goes in the notebook, with their name on it.
Everything else is a verb: a request the server validates and applies. A verb sets a flag on a person, reveals a fact, or nudges a disposition — that is deliberately all it does. What those mean is the callout's business, through its rules, which is why adding a verb never means editing a callout.
| Verb | Takes | What it does, and what it needs first |
|---|---|---|
| Ask for ID | a person | Against their compliance. Co-operative and they hand you a licence — a drawn card with a name, date of birth, licence number, address, height, weight, eyes and hair, and whether the licence is valid, suspended or expired. Uncooperative and they have not got any on them, and compliance drops. |
| Run them | a person | Needs an identification first. Returns the warrants and flags the callout gave them, named — and files the licence in the case file whether or not they handed one over. With the radio, the return is answerable on somebody who exists nowhere else. |
| Run the plate | a vehicle | Stolen, unregistered, wanted. |
| Search them | a person | Needs them detained, cuffed, arrested or down. Finds what their role owns and nothing more specific has claimed. |
| Search the vehicle | a vehicle | Finds the vehicle's own facts and any contraband. |
| FST: eye test FST: walk and turn FST: one-leg stand | a person | The field sobriety test, in the real order, and the person performs it: an impaired subject walks the line badly and goes down during the balance. Each test is scored in clues — 4 of 6, 2 of 8, 2 of 4 to fail — written in the notebook as you go. An impaired person shows the fail threshold or more; a sober one shows a clue at most, which is nerves or a bad knee and never enough. Needs you to have talked to them first, needs them out of cuffs, and they have to agree to it. |
| Breath test | a person | Gives the number — the three tests above show impairment, and this is what puts a figure on it. Uses a breathalyzer item if your inventory has one — and the item check fails open, so a server with no such item is never locked out. |
| Order hands up Tell them they are detained Tell them they are free to go | a person | The three orders, and each one is a request with an answer rather than a button that works. The same order gets a different answer from the same person two minutes later depending on how they have been handled. |
| Cuff them | a person | Needs them detained. Cuffing somebody standing up and uncooperative is not a given: they may pull away and run, or fight. Down or willing, it is routine. Whatever your server already uses for cuffing gets first refusal. |
| Uncuff them | a person | Takes the cuffs and the arrest back off. |
| Take hold of them | a person | Needs them cuffed. They walk with you. |
| Let go of them | a person | They stand where they are, still cuffed. |
| Put them in the vehicle | a person | Needs them cuffed, and you standing at a vehicle. The client says which vehicle and the server checks you are really next to it. |
| Transport them in | a person | Needs them cuffed and in a vehicle. Driving away with somebody standing on the curb skipped the whole point of the arrest, so the custody chain is cuff, take hold, put in the car, transport. |
| Take a written statement | a person | Needs you to have talked to them. Puts what they said on the record and fills in gaps. |
| Photograph the scene | the scene | Puts it on the record. Some callouts want it before they will close well. |
| Seize property | a person | Needs them searched. Goes into sals_kewlevidence when you run it, and into the scene record otherwise. |
| File the report | the scene | The verb that closes most police calls: several outcomes require it. |
| Set up traffic control | the scene | Any service. Refused where there is no roadway to control. |
Everybody in a scene has paperwork
None of these people exist, which is exactly why they need documents. So every person in a call gets a full identity the moment it opens — a name, a date of birth, a licence number and class, an address, the physical description off the card, and whether the licence is any good.
It is built at the start rather than when somebody asks, because the card has to agree with the call: a callout that rolled a suspended licence hands over a card that reads SUSPENDED, and an impaired-driver call produces somebody old enough to have been drinking. The name an officer reads off the card is the same name the radio lookup returns, the same name in the log, and the same name in the after-action report.
The card is American by construction: a state driver's licence, the date MM/DD/YYYY, the height in feet and inches, the weight in pounds. It is drawn rather than an image, because it carries a different name every time.
People who run, and people who fight
A pursuit is part of the model rather than a cutscene. Somebody whose flight value crosses the line runs, and then: get within about 3.5 m for a moment and they are caught; let every unit get 90 m away and you are losing them; 180 m from the scene and they got away — which is a real ending, recorded as such in the report. A half-hearted runner gives up after about a minute and a half on their own.
A tased person is incapacitated for a configurable spell, and cuffing somebody who is down is routine. Your server's own cuffing script, taser and jail system keep doing their jobs; this resource asks them rather than replacing them.
The fire model
A fire is a set of seats — discrete burning spots, each with its own intensity. A dumpster is two seats, a room and contents is four, a working structure is a dozen. One model serves all of them.
- The server owns which seats are burning. That is the authoritative state, and it is what the objectives and the report read. Every client near the scene draws those seats and keeps its own fires in step with the list, because a script fire is local to the client that started it.
- It spreads on a clock. Each burning seat has a small chance per tick of lighting an unlit seat near it. Ignore a kitchen fire and you will be fighting a house.
- It flashes over if it is left long enough, which is the point at which the inside of the building stops being somewhere to be.
- Venting halves the spread rate. A real decision with a real effect, not a flavor animation.
- Water is measured, not assumed. We do not rely on the game's own extinguisher behavior against script fires. A client applying water reports it, the server checks the claim against where that player actually is and how long it has really been, and credits that seat's water budget. A seat goes out when enough water has landed on it.
Which is what lets the report say the true thing afterward: time to water, time to knockdown, and who put it out.
The fire verbs
| Size up | Once per scene, by one person. Reveals what the building is and whether anyone is unaccounted for, and puts it on the board for the whole crew before anybody commits to the interior. |
|---|---|
| Force entry | Makes entry a fact of the scene. |
| Primary search | Finds the people who are in there. Refused while the interior is untenable and unvented — vent it first. A found victim becomes a fact, an objective to remove them, and a page to EMS. |
| Ventilate | Vents the structure. Spread slows. |
| Remove them to the exterior | Carries a victim out. With no medics on duty, this calls the NPC ambulance. |
| They are deceased | Fire or EMS. Pronounces, and calls the medical examiner. |
Player medical state is not ours. This resource models
NPC patients only. A downed player is entirely
wasabi_ambulance's business, and there is no path from here into
it.
If you already run a fire script
Smart Fires, Smart Hose, z_fire, z_hose, z_ppvfan, z_aerial, Fire Tools, zTurnout‑System. All optional, all detected when your server starts, and the fire above works exactly as described with none of them installed.
There is one rule behind every setting on this page, and it is worth stating before the table:
This resource stays in charge of the scene. The call, the objectives, the primary search and the after-action report are always ours. What these integrations change is who holds the hose and who draws the flames — not who owns the incident. That is deliberate: the report is the thing you would lose by handing the fire to another script, and the report is the point of the resource.
| Smart Hose (full or Lite) | Their hose puts our fire out. Water is credited at the handline rate, against the seat it actually landed on, and the firefighter is named in the report. The full edition also tells us where the stream is hitting, which is better than our own aiming check — it gets the arcing-over-a-wall case right. The Lite edition needs no setup at all: its hose is a weapon, and we read the weapon. |
|---|---|
| z_hose | The same, through its own “am I spraying” export. Its pump supply also completes the water‑supply objective. |
| Smart Fires (full) | Draws our fire with its presets, so a transformer burns electrical and a gas leak burns chemical — something this resource could not previously express. Fires it starts also become real calls here: see below. |
| z_fire | The same flame layer, driven with its spread turned off so our model still owns the fire. |
| z_ppvfan | Setting a fan up and running it near the scene vents the building, which halves the fire’s spread. Venting has always been in the model; until now only a callout author could declare a building vented. This makes it something a crew does. |
| z_aerial | A get the aerial set up objective: outriggers on the ground, stick off the bed. Getting the cage above the roof also vents the building — cutting the roof and putting a fan in the door are two ways to do the same thing, and the model only cares that it happened. |
| Fire Tools + Smart Fires | Extrication through their rescue flow: the saw, the door, the carry, the NPC flatbed — performed on the trapped occupant we already spawned, so there is one victim and not two. If anything about it is missing, our own Jaws of Life and carry run instead. |
| zTurnout‑System | Station turnout alerts, for a server with no dispatch resource. |
| Vehicle Rescue (London Studios) | Adds a stabilize the vehicle objective to extrications. The firefighter marks it done rather than us measuring it: that resource publishes nothing a script can read, and we would rather say so than pretend we checked. |
The one setting most people need
Everything here is configured from the Other fire scripts
page of /pssadmin. Most of it is safe to leave alone. This one is
not:
Use our own hose — turn it off if you run Smart Hose or z_hose. Left on, your firefighters can carry two hoses: ours and theirs. Their water counts toward the knockdown either way, so turning ours off costs you nothing.
A fire somebody else started
Smart Fires announces every fire it creates, and two of those are real calls that nobody is normally dispatched to:
- Arson. Somebody sets a fire with a lighter. It becomes a call here — paged, with a size‑up, a primary search, a knockdown objective and a report, and the cause marked undetermined for your PD to work out.
- Explosions. Something goes up in the world and the fire that follows is a call.
Both are on by default and capped like any other call, so a player setting twenty fires gets you a busy shift rather than twenty scenes. Smart Fires’ own random incident spawner is deliberately not adopted — it has one and so do we, and two scripts inventing callouts at once is a shift nobody can keep up with.
Letting Smart Fires own the burn
If you bought Smart Fires for its tablet, its live map, its wind and its fire projection, Who owns the burn puts those on our callouts. It is off by default, and it costs you something real:
With it on, the after-action report can no longer say precisely who put the fire out, because Smart Fires does not tell anybody which firefighter damaged which flame. Time to knockdown survives. Time to water becomes approximate. We still record every firefighter’s water and still forward it to their fire tagged with that player, so most of the credit survives — but not all of it.
Lite editions, and why the boot log matters here
Smart Fires and Smart Hose both ship a Lite edition, under
the resource names SmartFiresLite and SmartHoseLite,
and those do not carry the full versions’ developer exports. A script
asking the full name for something the Lite edition does not publish gets
nothing back — silently, with no error anywhere.
So this resource does not assume. It tries each function once when your server starts, records whether it answered, and says so:
[sals_kewlpublicsafetysimulator] WARNING: SmartHoseLite is running but
publishes none of the integration exports this resource looks for. That is
expected of the Lite edition. Our own fire is being used, and your hose still
counts.
/pssdebug prints the same thing on demand, along with every
resource name that was looked for. If a fire integration is not doing what you
expect, that is the first place to look — and a renamed resource folder
is the most common cause.
NPC EMS, the coroner and the tow
The problem these solve: a fire callout with a victim cannot close on a server with no medics on, and a crash cannot close with nobody to take the car. So the resource brings its own — carefully.
- They come only when nobody is covering that job, by default. A server with medics on duty never sees an NPC ambulance.
- Your real units get first refusal. There is a grace period before an NPC unit is even requested, and a medic attaching to the call while an NPC ambulance is still driving turns that ambulance around.
- Nothing spawns on request. A requested unit is a timer and a string: the panel shows an ETA and the world is untouched. Only when it is nearly due does a vehicle appear — out of sight, on a connecting road 150–250 m out.
- It drives one leg only, parks at a curb slot the location author wrote down, and walks the last twenty meters. That is the single biggest reason NPC units in this resource do not end up wedged in a hedge.
- A watchdog fixes a stuck unit with remedies nobody can see, and no scene's completion ever depends on a drive succeeding. If the ambulance cannot get there, the patient is handled anyway and the report says so.
Five ship, each with its own mode in the settings — on request, when nobody is available, always, or never:
| EMS | Transports a viable patient, or pronounces one who is not and calls the medical examiner. The only one that defaults to when nobody is available. |
|---|---|
| Coroner | The medical examiner, for a body. |
| Tow | A flatbed for the car. |
| Prisoner transport | The van, for when somebody is in custody and the arresting officer would rather keep working than drive to jail. |
| Taxi | A cab for the people who are not under arrest — the sober passenger, the motorist whose car just got towed, the witness who gave a statement. They have to get home, and leaving them standing in the road is how a scene fails to end. |
Animal control, mutual-aid fire and the road crew are named but ship off: a callout may ask for one, and the request is refused at runtime rather than failing.
Police dogs and other working animals are protected by model name and by state key: the resource refuses to treat one as a scene person, and says so loudly rather than quietly doing something strange to your K9.
Commands
| Command | Who | What |
|---|---|---|
/pss or F6 | everybody | The panel: calls holding, your scene, the fact board, your actions. |
| F7 | everybody | Pins the pocket notebook open, or puts it away. It shows itself for a few seconds on every new line regardless. |
/pssoff | everybody | Silence pages for yourself. Allowed by default, because the alternative is somebody logging off instead. |
/pssme | everybody | Why am I not getting calls? Prints what this resource thinks you are — the framework it found, your job and grade, the raw duty field straight off the framework beside our reading of it, which service that maps to, your callsign, and whether you are eligible with the reason if you are not. Deliberately not admin-only: the person it is happening to should not have to find an admin. |
/psspoint [label] | everybody | Prints a pasteable location point where you stand. Not ace-gated — it reads your own position and changes nothing, so location work can be handed to a builder without handing over /pssadmin. See Checking and fixing locations. |
/pssadmin | admin | Setup: presets, every setting with its own help, the catalog, and opening or closing a scene by hand. |
/pssdiag | admin | The diagnostic. Start here when something is wrong. |
/pssdebug scenes | stage | aux | perf | verbs | admin | Live state, in the console. |
The command names and the two keys are the one part of the configuration
that is not in the panel — a command has to be registered when the
resource starts, before any panel exists. They live in
config.local.lua; see Your settings, and
updates.
Configuration and presets
Everything is edited in /pssadmin, with plain-language help on
every field and validation as you type. There is no config file to edit. The
groups, and the settings worth knowing about:
Pace — the Director
| Interval | Seconds between candidate incidents, as a range. Default four to fifteen minutes. The Director waits a number in that range, then opens something if the ceilings allow. |
|---|---|
| Ceilings | At most six scenes active, three of them unanswered, and one scene per player at a time. The honest failure mode of an incident generator is too much of it. |
| Minimum units online | Counted per service. A fire callout is not worth opening with no firefighters on. |
| Cooldowns | Thirty minutes before the same callout runs again, twenty before another call in the same cooldown group (which is what stops three different violent calls landing back to back), and fifteen before the same location is used again. |
| Match the call to the shift | On by default. A callout's chance of being picked is scaled by how well the units it wants matches the units actually on duty, so a quiet night produces traffic stops and a full shift produces working fires. Nothing is ever excluded by it — a one-officer server can still roll a structure fire, it is simply less likely than the trash fire. Turn it off for a flat weighted pick. |
| Distance | 200 m to 2.5 km from the nearest unit, so a call arrives as somewhere to drive to rather than appearing on top of somebody. |
The broadcast
| Re-page | Every two minutes for a call nobody has taken. Zero turns it off. |
|---|---|
| Expire | Fifteen minutes, then the call closes quietly. |
| Blip | Drawn for an unattached call, so somebody can go find it. |
| Close on last clear | On by default. Turn it off and a cleared call goes back in the queue for somebody else to look at. |
There is no setting that turns the broadcast into “assign it to one player”. That is the rule the resource is built on.
Limits
Ten people, four vehicles, twelve props and twenty-four fire seats per scene; two NPC units per scene and four server-wide; a staging radius of 250 m. These are the performance budget rather than suggestions — they are what keeps a busy evening cheap. Raise them knowing that.
Fire
Seconds of water to knock a seat down, the range and cone within which water counts, spread chance and radius, what venting multiplies spread by, and how long before flashover. Water-supply objectives are here too, and they ship off: a server with three firefighters and no hydrant script should not be fighting the resource.
Police
Which script does the cuffing (built-in, an export, or an event — it
defaults to qb-policejob's), how long a tasing lasts, the distances
that decide caught, losing and escaped, and how much weight an order
carries.
The catalog — turning calls off without editing a file
Every callout carries tags. Put a tag in the catalog's exclude list and no callout carrying it is ever opened — no file to edit, nothing to delete, and it survives an update. The tags the shipped catalog uses:
traffic · property ·
violent · weapons · drugs
· alcohol · medical ·
crisis · death · fire
· hazmat · rescue ·
alarm · report ·
assist
A community that does not want drug calls excludes drugs. One
that wants nothing with a body in it excludes death. It is the
right way to tune the catalog to what your server actually roleplays.
Services and integrations
Your job names per service, whether on duty is required, and the resource names of the optional integrations. Each integration is detected at runtime, every call into one is wrapped, and the resource is complete without any of them.
Checking and fixing locations
A location is four numbers in a file, and nothing about reading it tells you that the point is inside a wall, that the curb slot is in a hedge, or that the approach node is on the far side of a freeway with no way across. You find that out when a call opens there and a crew drives to a fence.
So there are two tools, and between them a server with its own MLOs can have its own storefronts, bars and apartment blocks without writing coordinates by hand.
The inspector: walk a callout's locations
/pssadmin → Check a scenario's locations.
Pick a callout and load it, and you get every point it can use with what it
has and what it is missing — curb slot, approach, edited, turned off.
Then, per point:
| Go there | Teleports you to it. The scene marker is drawn in blue, the curb slot in gold and the approach in green, so you can see what is wrong rather than imagine it. |
|---|---|
| Scene is here | Moves the point to where you are standing, facing where you are facing. |
| Curb slot here | Puts the NPC parking slot where you stand. |
| Approach here | Puts the out-of-sight approach node where you stand. |
| Turn off | Takes one bad point out of the pool without touching the rest of the set. |
| Undo my edits | Drops your override. The live point keeps the edited values until the next restart, and it says so. |
It never edits the shipped files. What you change goes in
data/locations.json as an override keyed to the point it
replaces — the same rule the settings store lives by. The files in
locations/ are ours and an update overwrites them; your
adjustments are in data/, which an update never touches.
An override carries the coordinates it was made against. If a later release reorders or replaces a set, the override no longer matches the point it was written for, so it is refused and reported at boot rather than silently moving some other location somewhere nobody intended. That check is the whole reason this is safe to ship.
/psspoint: write a new one by standing in it
Stand where the scene should be, face the way a responder should face, and run:
/psspoint the 24/7 on Innocence
It prints a finished, pasteable point table to the F8 console —
coordinates rounded to one decimal, the heading you are facing, and a curb
slot a few meters behind you. Then walk to the hydrant and run
/psspoint hydrant, or to the approach and run
/psspoint approach, and it prints just that line to drop into the
point you already made.
It goes to the F8 console rather than chat because the output is several lines of Lua with braces in it, and chat truncates, cannot be copied, and shows it to whoever is standing next to you.
It is not ace-gated: it reads your own position and
changes nothing, so location work can be handed to a builder without handing
over /pssadmin. Paste the result into a file of your own under
locations/ and see Locations for the
shape.
Your settings, and updates
data/settings.json holds only what you changed
from the shipped values, and the download does not contain that file. So:
- An update cannot overwrite your configuration. Replace
the folder; keep
data/. - If a default you never touched gets better, you get the improvement.
- A new setting arrives with its new default rather than half-existing.
config.local.lua works the same way. The manifest globs
config.local*.lua, so the zip never carries that name and the
packaging script refuses to build one that does. The template ships as
config-local-EXAMPLE.lua, spelled so the glob cannot match it.
Almost nobody needs it: the panel covers everything except the command names
and the panel key, which have to exist before the panel does.
Both files are checked. /pssdiag names any key in either one
that nothing reads — with a suggestion when your spelling is one edit away
from ours, because a misspelled setting that silently changes nothing is the
worst kind of configuration bug.
Performance
Nothing in this resource runs every frame on the server. The only frame-rate loop on the client is the on-screen prompt, and it sleeps at 500 ms until you are within a few meters of something you can act on.
- Idle, with no call open: one ten-second thread. That is all.
- A call nobody has driven to costs one table. No ped, prop, vehicle or fire exists until a responder is within 250 m — and it is all torn down again, facts intact, when everybody leaves.
- Scene logic runs at 1 Hz, and rules also evaluate the instant a fact changes. That is what makes a scene feel responsive without polling for it.
- Hard caps on scenes, people, vehicles, props, fire seats and NPC units, all in the panel.
/pssdebug perf prints the numbers, so “this feels
heavy” can be answered with a figure instead of an impression.
Troubleshooting
Run /pssdiag first. It answers most of what follows on its
own.
No calls ever happen
- Jobs not mapped. The most common cause by a wide margin.
/pssdiagprints the job names it matched and the services that have none. - Nobody on duty. On duty is required by default, and the Director will not open a call for a service with nobody on.
- Everything is cooling down, or the caps are already met. Both are printed.
- No usable location — everyone is standing on the only spots it would have used. Drive somewhere else, or widen the distance range.
A page reached some people and not others
Those players are off duty, in a job that is not mapped to that service, on
another call already (one scene per player by default), or they have run
/pssoff.
Have the player run /pssme. It prints the raw
duty field straight off the framework beside this resource's reading of it, and
the two disagreeing is the actual failure mode: a duty script that does not
write the field qb-core reads leaves somebody on duty everywhere except here,
and nothing short of seeing both numbers tells you that. It also prints which
job list was consulted and what is in it, and says ELIGIBLE or NOT ELIGIBLE
with the reason.
A crew drove to a fence, or an NPC unit parked in a hedge
That point needs fixing, and you can fix it without editing a file:
/pssadmin → Check a scenario's locations, walk to it,
and say where the scene, the curb slot and the approach should be. See
Checking and fixing locations.
The call exists but the scene is empty
Nothing is created until somebody is within 250 m. If you are closer than that and still see nothing, check the console for a line about a role that could not be created: a per-scene entity cap or a bad ped model will be named there. A scene with a missing witness carries on deliberately — a worse scene beats a broken one.
A callout I added is not in the catalog
In order: the resource has to be restarted; the file has to be under
scenarios/; and the boot log will have said either
REFUSED with the reason, or nothing at all — and nothing at
all means the file failed to load as Lua, which prints further up the console.
See Checking your work.
An NPC ambulance never arrived
By default the NPC services come only when nobody is covering that job, and there is a grace period first. With a medic on duty you will not see one, and that is the intent. The panel's Call for section shows the state and the ETA of anything that was requested.
The fire will not go out
Water counts when you are within range and roughly pointed at the seat, and
a seat needs a measured amount of it. /pssdebug scenes shows the
seats and their water. An intensity above 1.0 means the callout author made that
fire deliberately stubborn.
Developers: what a callout is
Everything from here on is for whoever writes the calls. You do not need to be a Lua programmer to do it, and that is deliberate.
A callout is a table, not code. It says who is in the
scene, what is true, how it moves, and how it can end. The conditions in it
are written in a tiny expression language of our own — ten roots, six
comparators, and / or / not — which
is parsed once when the file loads and can do nothing it was not designed to
do.
That design choice is the whole point. scenarios/ is a folder
we invite you and strangers to put files in, and callout files are meant to be
shared: posted, downloaded, forked, dropped into somebody else's
server unchanged. A string of Lua run through load() is not a
sandbox; it is an arbitrary-code hole that arrives looking like content. A
table with a small grammar is data, and data cannot do anything it was not
designed to do.
The cost is that the grammar is small. That turned out to be a feature: the whole vocabulary fits in this section, which is also what makes a callout writable by hand.
Where files live
Anywhere under scenarios/. Subfolders are fine — the
shipped catalog uses scenarios/police/ and
scenarios/fire/ — and so is a single file dropped straight
into scenarios/. The folder is globbed by the manifest, so:
- There is no manifest to edit and no list to join. Nothing anywhere else needs to know your file exists.
- Adding a callout is copying a file in and restarting the resource:
restart sals_kewlpublicsafetysimulator
Two function names register one, and they are the same function:
Scenario.Register({ ... })
Callout.Register({ ... }) -- the same thing, under the name everybody else uses
This resource calls them scenarios; the rest of FiveM calls them callouts. Being strict about our vocabulary at the cost of somebody's first file loading would be a bad trade, so both work and neither is deprecated.
Nothing broken loads quietly
Every callout is validated when it registers. A malformed one is refused by name, with the file, the line and the reason, and the rest of the catalog still loads. The three mistakes that are completely silent without that check:
- A rule that reads a fact or a role the callout never declares —
fact.armedwhere you declaredsuspect_armed. An undeclared path is simply never true, so the condition would never fire and nothing would ever say why. - A location set that does not exist, or one with no curb slot while the callout asks for a tow truck.
- An action name nothing implements.
esculate(2)is a typo that would otherwise do nothing forever.
Your first callout
Save this as scenarios/police/my_first_call.lua and restart the
resource. It is complete, it is valid, and it is a real call: somebody is
reported in a parking lot who should not be there, and one time in five they
are wanted.
Scenario.Register({
-- Identity. The id has to be unique and may only contain letters, numbers
-- and underscores. Give yours a name of your own and it can never collide
-- with one of ours.
id = 'my_first_call',
title = 'Suspicious person',
service = 'police', -- who gets paged: police, fire, ems, or a list
priority = 4, -- 1 emergency .. 4 public assist
weight = 20, -- how often it comes up, relative to everything else
units = { min = 1, ideal = 1 },
tags = { 'report' }, -- so an owner can switch off calls like this one
locations = 'parking', -- a set registered in locations/
-- WHAT DISPATCH SAYS, which is not the same as what is true. One of these
-- is picked per call, and it is what the page carries.
as_reported = {
'caller reports somebody looking into cars',
'caller reports a person in the lot who should not be there',
},
-- WHO IS IN IT.
roles = {
person = {
label = 'the person in the lot',
npc = { models = { 'a_m_y_downtown_01', 'a_f_y_hipster_02' } },
offset = { x = 1.0, y = 1.5, z = 0.0 }, -- from the call's coords
-- Which of the scene's facts belong to this person, and which verb
-- uncovers them.
facts = { 'person_name', 'person_wanted', 'person_is_resident' },
onId = { 'person_wanted' }, -- running them
onInterview = { 'person_name', 'person_is_resident' }, -- talking to them
-- WHAT THEY CAN BE ASKED. Every person can already be asked "what
-- happened here?", "are you hurt?" and "did you see anything?" --
-- these are the ones that are about this call.
questions = {
{ id = 'why_here', text = 'What are you doing out here?',
says = "I live in the building. Second floor.",
lie = "Just waiting on somebody.",
reveals = { 'person_is_resident' } },
-- Only askable once you know they live here, which is how a
-- conversation opens up as it goes.
{ id = 'which_unit', text = 'Which apartment?',
requires = 'known.person_is_resident',
says = "2B. I've got my key on me." },
},
-- How they behave. All four are 0..1 and all four move.
disposition = { compliance = 0.65, flight = 0.2, violence = 0.02, honesty = 0.5 },
},
},
-- WHAT IS TRUE. Rolled once, when the call opens, and never re-rolled.
facts = {
person_name = true,
person_wanted = { chance = 0.2, hidden = true }, -- nobody is told
person_is_resident = { chance = 0.5, hidden = true },
},
-- HOW IT MOVES. `known.x` is "the crew has found this out", which is not
-- the same as `fact.x`, which is "this is true whether or not they know".
rules = {
{ when = 'known.person_is_resident',
then_ = 'say("they live in the building and have a key")' },
-- Somebody who hears their own warrant read out gets twitchy.
{ when = 'known.person_wanted and not role.person.detained',
then_ = 'disposition(person, "flight", 0.4) + say("they keep glancing at the street")' },
-- The disposition crossing the line is what actually makes them run.
{ when = 'role.person.disposition.flight > 0.6 and not role.person.restrained',
then_ = 'flee(person) + escalate(1) + say("they run")' },
},
-- HOW IT CAN END. Lower `order` wins when more than one is satisfied.
outcomes = {
got_away = {
order = 1, quality = 'poor', label = 'They got away',
requires = 'role.person.fled_scene',
},
arrest_made = {
order = 2, quality = 'good', label = 'Arrest made',
requires = 'role.person.restrained and report.filed',
},
moved_along = {
order = 3, quality = 'acceptable', label = 'Checked out, moved along',
requires = 'role.person.spoken_to and report.filed',
},
},
})
What happens when it runs: an officer is paged to a parking lot with a line about somebody looking into cars. Ask them what they are doing out here and half the time they live in the building and have a key, which is the end of it — unless they are nervous enough to lie about it, in which case the question stays there and an officer who settles them down can ask again. Ask for ID, run them, and one time in five there is a warrant — and the moment that lands on the board their flight climbs, so an officer who is slow to put hands on them watches them run. Catch them and it is an arrest; lose them and the call closes as They got away, which is a real ending rather than a failure state.
Three things in that file are the whole authoring model, and the rest of this manual is detail on them: facts that are hidden until somebody does the work, dispositions that move, and outcomes that describe what happened rather than scoring it.
Before you restart, check it from a terminal in the resource folder:
python tools/validate.py
That runs the server's own validator against your file and tells you what is wrong with it, by name, in a second. See Checking your work.
Anatomy of a callout
Every field, what it is for, and what happens if you leave it out.
| Field | Required | What it is |
|---|---|---|
id | yes | Unique. Letters, numbers and underscores only. Registering an id that already exists replaces it — that is how you override one of ours. |
title | yes | The line a responder reads on the page. Write it the way dispatch would say it. |
service | yes | 'police', 'fire', 'ems', or a list such as { 'fire', 'police' }. Every service listed must have at least one unit on duty before the call will be opened at all. |
priority | no (3) | 1 emergency, 2 urgent, 3 routine, 4 public assist. Drives the page styling, the tone-out, and a small nudge to how often it comes up. |
weight | no (10) | Relative likelihood. Must be above zero, or the callout can never be chosen. |
units | no | { min = 1, ideal = 2 }. Carried on the page so a crew knows what the call wants — and ideal is weighed against who is actually on duty: a four-unit call is several times less likely on a two-officer server, without ever being impossible. |
as_reported | no | What dispatch says, which need not be the truth. A line of text, or a list of them with one picked per call. Without it the page carries the title. |
tags | no | Single words. A server owner excludes a tag and no callout carrying it is ever opened. Use the shipped vocabulary where it fits: traffic, property, violent, weapons, drugs, alcohol, medical, crisis, death, fire, hazmat, rescue, alarm, report, assist. |
cooldown_group | no | One word. Every callout sharing it shares a cooldown, so three different violent calls do not land in a row. |
conditions | no | When this call may come up at all — hour, weather, units on duty. See below. |
locations | yes | Three shapes: the name of a set registered in locations/, a list of set names pooled together, or this callout's own inline list of points. |
roles | no | The people. Each needs an npc block. See Roles. |
facts | no | What is true. See Facts. |
rules | no | A list of { when = '...', then_ = '...' }. See Conditions. |
outcomes | yes | At least one, or the scene can never close. Two or three is what makes a callout worth running twice — the validator warns at one. |
vehicles | no | { { model = 'sultan', offset = { x = 0, y = 0, z = 0 }, heading = 90.0 } }. Created at staging, counted against the per-scene vehicle cap. A vehicle may carry when_fact = 'x' and is then only there when that fact rolled true. |
props | no | The same shape, for scenery. |
fire | no | The fire block. See Writing a fire callout. |
aux | no | The NPC services this callout may ask for: { 'ems', 'coroner', 'tow' }. |
author, version | no | Carried through to the catalog listing in /pssadmin. Put your name on a file you intend to share. |
What dispatch says, and what is true
title is the catalog name — the admin list, the
after-action report. as_reported is what goes out on the page, and
the gap between the two is most of what makes a call worth driving
to:
title = 'Shoplifter in custody',
as_reported = {
'store staff holding a shoplifter',
'caller reports a theft, suspect held by staff',
'loss prevention has somebody detained',
},
One line is picked per call. Write them the way a caller actually gets it wrong: vague, partial, sometimes confidently mistaken. A page that says “a man with a gun” for what turns out to be a phone is a better evening than a page that reads like a case file — and when it is a gun, the crew who took the report seriously were right to.
When a call may come up at all
conditions = {
hour = { 21, 4 }, -- 9pm to 4am. Wraps midnight.
weather = { 'RAIN', 'THUNDER' }, -- only in this weather
exclude_weather = { 'XMAS' }, -- never in this weather
min = { police = 2 }, -- needs two officers on duty
},
cooldown_group = 'violent', -- shares a cooldown with every callout naming it
Everything is optional, and no conditions block means any time,
any weather — which is what almost every callout wants. Reach for it when
the call genuinely does not make sense otherwise: a bar fight at nine in the
morning, or a four-unit raid with one officer on.
Note the two ways to say “this needs a crew”, because they are
different. units.ideal makes a big call less likely on a
thin shift and still possible — a brave officer may want it, and refusing
to offer it is not our decision to make. conditions.min makes it
impossible. Prefer ideal; use min only when
one unit on their own would be a bad evening rather than a hard one.
Everything is checked. A priority of 7, a weight of 0, a disposition of
1.4, a service called 'policee', an hour of 25, a location set
nothing registers, an action nothing implements, a rule reading a fact you
never declared — each one is refused with a sentence telling you which it
was.
Facts and the fact board
A fact is one true thing about this call. Facts are the memory of the scene, the reason a crew's work accumulates, and what the rules and the outcomes read.
Declaring them
facts = {
driver_name = true, -- always true
vehicle_insured = false, -- always false
license_suspended = { chance = 0.28 }, -- true 28% of the time
stolen_value = { roll = { 15, 420 } }, -- a number in that range
warrant_active = { chance = 0.12, hidden = true },
}
Rolled once, when the call opens, and never re-rolled. A crew who searches twice gets the same answer twice, which is the only honest way for a fact board to work.
Hidden, and the difference between fact and known
This is the single most important idea in the whole authoring model. There are three ways to ask about a fact and they mean different things:
fact.x | The truth, whether or not anybody has found it out. |
|---|---|
known.x | The value, once discovered. Nothing until then. So known.warrant_active is true only when the crew has run them and there is a warrant — and known.bac > 0.08 reads the number they actually got. |
checked.x | True once it has been looked into, whatever the answer was. The rarer question, and it gets the longer name. |
known.x is the value and not the discovery flag, which is
the distinction to get right. A warrant that rolled false and then got run
makes checked.warrant_active true and leaves
known.warrant_active false — so a rule written on
known. fires on a warrant that exists, and never on a clear
return. Reach for checked. when what matters is that somebody
bothered to look:
{ when = 'checked.warrant_active and not known.warrant_active',
then_ = 'say("the return is clear")' },
A fact declared with hidden = true starts undiscovered: it is
true in the world and nobody on the call has any idea. Everything else starts
known, because the caller said it on the phone.
So a rule written against known. is a rule about information,
and a rule written against fact. is a rule about reality:
-- Information changes the job. The crew learns somebody is inside, so the
-- search becomes urgent. THIS is the rule you want most of the time.
{ when = 'known.occupants_unaccounted',
then_ = 'escalate(1) + objective("primary_search", "Somebody is unaccounted for -- search it")' },
-- Reality, regardless of who knows. Nobody went in and the fire took the room.
{ when = 'fact.occupants_unaccounted and scene.age > 540 and not role.occupant.rescued',
then_ = 'set(fatality, true) + request_aux(coroner)' },
Writing fact. where you meant known. produces a
callout that behaves as though the crew is psychic. It is the most common
authoring mistake after a misspelled fact name, and unlike that one the
validator cannot catch it — both are legal.
Who finds what
A hidden fact becomes known when a verb reveals it. Which verb is decided by the role that owns the fact (next section), or by the verb's own rules:
| Fact name | Found by |
|---|---|
plate_stolen, vehicle_unregistered, vehicle_wanted | Running the plate. |
Anything starting vehicle_, or containing contraband | Searching the vehicle. |
intoxicated | A field sobriety test, or a breath test. |
bac | A breath test, which invents the number if the callout did not. |
Anything starting structure_, plus occupants_unaccounted | A fire size-up. |
<role>_name | Asking that person for ID. The fact then carries the name off their licence, so declaring it as true is only a statement that the fact exists. |
license_suspended, license_expired | Written on the licence itself, so the card an officer is handed reads SUSPENDED or EXPIRED. Also found by running them, if the role lists them under onId. |
<role>_found | A primary search, for a role marked victim = true. |
Naming a fact to match one of those patterns is how you hook into a verb
without writing a rule for it. plate_stolen is found by running
the plate for free, in every callout, forever.
Facts the verbs set on their own
These appear on the board whether or not you declare them, and your rules and outcomes may read them — but only if you declare them, because an undeclared name is refused in a condition:
vehicle_searched, statement_taken,
scene_photographed, property_seized,
sized_up, entry_made,
primary_search_done, victim_found,
vented, fatality, traffic_controlled.
Declare the ones you want to use, usually as false:
facts = {
scene_photographed = false,
statement_taken = false,
}
Roles and dispositions
A role is a part in the scene. It is filled by an NPC, it has a name your rules refer to, and it accumulates flags as the crew works on it.
roles = {
suspect = {
label = 'suspect', -- what the panel calls them
npc = { models = { 'a_m_y_methhead_01', 'a_f_y_hipster_02' } },
offset = { x = 1.2, y = 1.0, z = 0.0 },
idle = 'wander', -- 'wander' (default) or 'victim'
armed = false, -- visibly armed from the start
facts = { 'suspect_name', 'warrant_active', 'suspect_armed' },
onId = { 'warrant_active' }, -- running them finds this
onInterview = { 'suspect_name' }, -- they will say this
onSearch = { 'suspect_armed' }, -- a pat-down finds this
disposition = { compliance = 0.55, flight = 0.30, violence = 0.05, honesty = 0.35 },
},
}
| Field | What it does |
|---|---|
label | The words the panel and the log use for them. Lowercase, as a sentence would have it: the homeowner, store clerk. |
npc | { models = { ... } }, or { model = '...' }. One is picked at random, so a list is worth writing. A role with no npc block is refused: nobody could fill it. |
offset | Where they stand, relative to the call's coordinates. |
idle | What they do before anyone touches the scene. 'wander' is the default; 'victim' is for somebody who is down and staying down. |
victim = true | Marks them as what a fire primary search looks for and what remove them to the exterior works on. Without it neither verb will touch them. |
when_fact = 'x' | This person is only here when that fact rolled true. The reckless driver who is gone before you arrive is a role with a when_fact, and the role is simply absent — so not role.driver.spoken_to is true and nothing else anywhere needs to know. This is what makes an honest “nobody here” call possible. |
armed = true | They are visibly armed from the start. Different from a hidden <role>_armed fact, which is a gun nobody has found yet. |
facts | Which of the callout's facts are about this person. Declaring a name here also declares it as a fact, so a role's own facts need not be repeated in the top-level facts table — though giving them a chance there is usually what you want. |
questions | The conversation this person can have — see Conversations. Without one they can still be asked the defaults. |
onId / onInterview / onSearch | Which verb uncovers which of those facts. Anything in facts that no list claims is found by a search, which is the sensible default: searching is the broad verb. |
disposition | The four numbers below, each 0 to 1. |
Who they are: the licence the scene invents
Every role gets a full identity when the call opens — a name, date of birth, licence number and class, address, physical description, and whether the licence is valid. Four optional fields on the role steer it, and the callout's own facts do the rest:
sex = 'M' / 'F' | Picked at random when you leave it out. |
minAge / maxAge | Bounds on their age. A callout that rolls intoxicated gets a minimum of 21 for free. |
noLicense = true | They carry a state ID card rather than a driver's licence. |
And from the facts: license_suspended or
license_expired rolling true puts that word on the card. Which is
the reason the identity is built at the start rather than at the counter
— a card invented when somebody asks would contradict the call.
The four dispositions
compliance | How likely they are to do as they are told. Drives whether an order is obeyed, whether they produce ID, whether they agree to a test. |
|---|---|
flight | How close they are to running. Crossing the line is what actually makes somebody run, and the line is a condition you write. |
violence | How close they are to fighting. |
honesty | Whether what they tell you is true. Honesty and compliance together decide whether they answer at all, which is why a frightened honest person still says nothing. |
They move. Talking to somebody takes flight down and compliance up a little each time. Refusing an order takes compliance down. A plate coming back stolen while they are still standing free takes flight up a lot. Those nudges are the verbs' doing; the consequences are yours:
-- Somebody who hears the plate come back stolen gets twitchy.
{ when = 'known.plate_stolen and not role.driver.detained',
then_ = 'disposition(driver, "flight", 0.45) + say("the driver is watching their mirrors")' },
-- The disposition crossing the line is what makes them run, so a crew who
-- talks calmly and covers the exits never sees this.
{ when = 'role.driver.disposition.flight > 0.62 and not role.driver.restrained',
then_ = 'flee(driver) + escalate(1) + say("the driver runs")' },
-- Two units on scene settles almost everything down.
{ when = 'scene.police > 1 and role.driver.spoken_to',
then_ = 'disposition(driver, "compliance", 0.15)' },
Write the starting numbers as a person, not as a difficulty setting. A
store clerk is compliance 0.95, honesty 0.95. A shoplifter caught
in the act is compliance 0.55, flight 0.30, honesty 0.35. A
homeowner whose house is on fire is compliance 0.8, honesty 0.95
and does not run anywhere.
Role flags
Flags are what a role accumulates, and they are what your conditions read
as role.<name>.<flag>. Every one of them is set by a
verb or by the scene, never by you:
| Flag | Set when |
|---|---|
spoken_to | Somebody talked to them, or took a statement. |
identified | They produced ID. |
searched | They were patted down or searched. |
detained | They were told they were detained, and accepted it. |
restrained | Cuffed. |
arrested | Cuffed; cleared again by uncuffing. |
transported | Taken away by a unit or by an NPC service. |
fled_scene | They ran and got away. A real ending. |
incapacitated | Tased, and on the ground until it wears off. |
escorted | Being walked somewhere by an officer. |
in_vehicle | Sat in the back of a unit. |
tested | A field sobriety or breath test was performed. |
deceased / pronounced | Dead; declared so by a medic or a firefighter. |
treated | They received any care. |
rescued | Removed from a hazard to the exterior. |
on_scene | Always true for an NPC. Present so a condition never has to ask what kind of occupant a role has. |
Conversations: what a person can be asked
“I can't ask where until I know he needs a ride.” That sentence is the whole design. A conversation is not one verb that dumps every fact; it is a set of questions, and which ones can be asked right now depends on what is already known — which is exactly how a person on a shoulder would experience being asked them.
roles = {
driver = {
label = 'driver',
npc = { models = { 'a_m_y_business_01' } },
facts = { 'driver_name', 'license_suspended', 'driver_armed' },
questions = {
-- Replacing a default by using its id. Every person can be asked
-- "what happened here?"; this driver gets a better version.
{ id = 'what_happened', text = 'Do you know why I stopped you?',
says = "I honestly don't. Was I speeding?" },
{ id = 'license_ok', text = 'Is your license valid?',
says = "...it might be suspended. I've been meaning to sort it out.",
lie = "Yeah, it's fine.",
reveals = { 'license_suspended' } },
{ id = 'weapons', text = 'Anything in the car I should know about?',
says = "There's a pistol in the glovebox. It's mine.",
lie = "No. Nothing.",
reveals = { 'driver_armed' } },
-- Only askable once the answer to the last one is in. This is the
-- mechanic: the conversation opens up as it goes.
{ id = 'why_suspended', text = 'Why is it suspended?',
requires = 'known.license_suspended',
says = "Unpaid tickets. A lot of them." },
},
},
}
| Field | What it is |
|---|---|
id | Required. Give it the id of a default question and yours replaces that default for this person. |
text | Required. What the officer says. |
says | What they answer. Left out, the engine narrates whatever the answer revealed, so a callout with facts and no dialogue still produces a sentence rather than a key name. |
reveals | Which facts that answer gives away. Only facts that are real, hidden and not yet known count as something to be honest or dishonest about. |
lie | What they say when they are not being honest. Left out, they give a plain evasion. |
requires | A when expression, the same little language the rules use — so a question can hang off anything a rule can read. |
repeatable | Askable more than once. Default questions that are conversation rather than information use it. |
Honesty, and why talking somebody down works
A question that would give something away is answered truthfully or not on that person's honesty and compliance. A lie reveals nothing — and it is not final: the question stays askable, so a unit who calms them down and asks again can get a different answer. That is the mechanic; the lines are just how it reads.
Which also means the four dispositions are the dialogue system. There is no persuasion skill and no dice roll to see: the officer who covered the exits and talked quietly gets answers, and the one who shouted gets evasions.
Every person can be asked the basics for free
| What happened here? | Hands over the role's interview facts one at a time, so a conversation has more than one beat. |
| Are you hurt? | Answered from <role>_injured and from a visible injury on a victim. |
| Did you see anything? | Answered from any fact of theirs named saw_..., ..._description or ..._witness. |
| Take a breath. Nobody is in trouble right now. | Pure de-escalation. Reveals nothing and moves the numbers that decide what the next question gets. |
So a callout with well-named facts and no questions block at
all still has a conversation. Write your own when the specific words matter,
which is most of the time for the person the call is about and almost never for
a bystander.
What the answers can say
says and lie may carry
%{some_fact} for a fact's value and %{name} or
%{first} for the person's own name off their licence, so an answer
carries the number rather than describing it:
{ id = 'how_much', text = 'How much did they take?',
says = "About $%{stolen_value}. Maybe more.",
reveals = { 'stolen_value' } },
Questions are validated at load like everything else: a missing
id or text, a requires that does not
compile, or a reveals naming a fact the callout never declares is
refused by name. A question nobody can ever ask would otherwise be completely
invisible.
Conditions: the when language
A when is a string, parsed once when the file loads. The whole
grammar:
expr := or_expr
or_expr := and_expr ( 'or' and_expr )*
and_expr := unary ( 'and' unary )*
unary := 'not' unary | comparison
comparison := primary ( ( '==' | '~=' | '!=' | '<' | '<=' | '>' | '>=' ) primary )?
primary := '(' expr ')' | number | string | 'true' | 'false' | 'nil' | path
path := word ( '.' word )*
There is no arithmetic, no function call and no assignment. A bare path is
truthy-tested: fact.suspect_armed means “is it true”.
Only nil and false are false — 0 and the
empty string are true, deliberately, because
fact.stolen_value being 0 means the fact exists and is zero, and
a rule asking about it wants a comparison.
The ten roots
| Root | Reads | Example |
|---|---|---|
fact | What is true, whether or not anybody knows. | fact.intoxicated |
known | The value, once the crew has discovered it. Nothing until then. | known.warrant_activeknown.bac > 0.08 |
checked | Whether it has been looked into at all, whatever the answer was. | checked.warrant_active |
role | A person: their flags and their dispositions. | role.suspect.restrainedrole.suspect.disposition.flight > 0.6 |
scene | The call itself. Fields below. | scene.police > 1 |
aux | An NPC service: requested, arrived, done. | aux.tow.arrived |
objective | An objective: open, done, failed. | objective.knockdown.done |
report | report.filed, and nothing else. | report.filed |
time | time.hour, the in-game clock. | time.hour >= 22 |
weather | weather.type, as the game names it. | weather.type == "RAIN" |
A root that is not one of those ten is refused at load, which is how
facts.armed (plural, a typo) gets caught instead of silently
never being true.
Scene fields
scene.level | 0 at first, raised by escalate. |
scene.age | Seconds since the call opened. The clock you write timeouts against. |
scene.state | pending, active, staged, closing, closed, expired. |
scene.staged | True once somebody is close enough that the world exists. |
scene.units | How many units are attached and not cleared. |
scene.police / scene.fire / scene.ems | How many of each service. The condition for “a crew, not one officer”. |
scene.command | True once somebody has command. |
scene.fires_burning / scene.fires_total | Seats burning, seats in total. |
scene.outcome | The winning outcome id, once there is one. |
scene.victims and scene.patients are accepted by
the validator but are not populated in 0.0.x — they are
reserved for the patient model. A condition reading one of them is never
true. Count a victim with a role flag instead:
not role.occupant.rescued.
Writing rules that feel right
rules = {
-- Each rule fires ONCE per scene by default. That is almost always what
-- you want: an escalation should happen once, not every second.
{ when = 'known.suspect_armed',
then_ = 'escalate(2) + disposition(suspect, "violence", 0.3)' },
-- `repeatable` re-arms a rule, for something that should keep happening.
{ when = 'scene.fires_burning > 5 and not fact.vented',
then_ = 'say("heavy smoke pushing from the eaves -- it needs venting")',
repeatable = true,
note = 'nagging, on purpose' },
}
Rules are evaluated on the one-second scene tick and immediately whenever any fact, flag or objective changes — which is what makes a scene feel like it noticed. Four things worth knowing:
- A rule fires once unless it carries
repeatable = true. - Rules are evaluated in the order you wrote them, and the scene is re-read after each one fires, so a later rule sees what an earlier one did.
- Two rules that set each other's condition will not loop forever: the engine fires them one layer at a time, four layers deep per pass.
- A rule whose condition errors at runtime is disabled for that scene, named once in the console, and the scene carries on. A content bug never takes a call down with it.
note is yours: it is carried with the rule and shown in the
debug output, and is the right place to say why a threshold is
0.62.
Actions: the then_ list
One or more actions, joined with +:
then_ = 'reveal(suspect_armed) + escalate(2) + say("there is a gun")'
Arguments are literals — a number, a quoted string, or a bare
name — and never expressions. escalate(level + 1) is not a
sentence a callout should be writing; what escalate means is the
scene's business. (Write then_ with the underscore.
then is a reserved word in Lua; the engine accepts
['then'] too if you insist on it.)
| Action | Arguments | What it does |
|---|---|---|
reveal | a fact | Puts a hidden fact on the board for the whole crew. |
set | a fact, a value | Sets a fact. set(fatality, true). |
escalate | a number (optional) | Raises the scene level, which re-pages the call and may bring more units. |
deescalate | a number (optional) | Lowers it again. |
end_scene | an outcome id (optional) | Ends the call now, with that outcome. Use it sparingly: an outcome's own requires is the better way to close a scene. |
flee | a role | They run. |
comply | a role | They do as they are told. |
resist | a role | They physically resist. |
surrender | a role | Hands up, done running. |
wander | a role | Back to normal behavior. |
disposition | a role, a name, a number | Nudges one of the four by an amount, which may be negative: disposition(driver, "compliance", -0.2). |
offer_verb | a role, a verb id | Makes a verb available on that person. |
objective | an id, text (optional) | Opens an objective. The crew sees it in the panel. |
complete | an objective id | Marks it done. |
fail | an objective id | Marks it failed. |
page | a service | Pages a service to this call. For a scene that grows into something else. |
request_aux | ems, coroner, tow | Calls an NPC service, subject to its mode and the grace period. |
say | a string | A line in the scene panel. Plain words, no locale key needed — this is the one place a callout writes English directly, because a shared file cannot ship a locale entry. |
ignite | a seat group (optional) | Lights a group of fire seats. |
vent | — | Marks the structure vented, which slows spread. |
An action name that does not exist, a role or fact name that this callout does not declare, a number where a number belongs — all refused at load, with the list of real action names printed for comparison.
Outcomes: how a call closes
An outcome is a condition plus a verdict. The scene checks them every time anything changes; the first one satisfied closes the call and writes the report.
outcomes = {
driver_fled = {
order = 1, quality = 'poor', label = 'The driver got away',
requires = 'role.driver.fled_scene',
},
arrest_booked = {
order = 2, quality = 'good', label = 'Arrest made',
requires = 'role.driver.restrained and report.filed',
},
citation = {
order = 3, quality = 'acceptable', label = 'Cited and released',
requires = 'role.driver.identified and report.filed',
},
warning = {
order = 4, quality = 'acceptable', label = 'Verbal warning',
requires = 'role.driver.spoken_to and report.filed',
},
}
order | Write it. Lower wins when more than one outcome is satisfied at the same moment — and in that traffic stop, three of them usually are. Without an order, the winner depends on Lua's table ordering, which is not stable between runs. |
quality | good, acceptable or poor. It is how the report describes the evening, not a score: the report says what happened. |
label | The sentence the report leads with. Write it as a result, not as a status: Occupant out, fire knocked down. |
requires | A when expression, with the same roots and the same rules. |
Two habits worth copying from the shipped catalog:
- Let a call end with nothing found, and score it as done
properly. A reckless driver who is gone before anybody arrives, a
shots-fired that is unfounded, a fire alarm that is a malfunction —
these are
goodoutcomes, not failures. Scoring them as failures teaches players to invent a reason to act, which is the opposite of the point. Pair an unable to locate outcome with awhen_factrole and the person genuinely is not there:
A unit who drove the stretch, looked, found nothing and cleared it did the job exactly right.facts = { still_here = { chance = 0.55 }, -- 45% of the time it is gone }, roles = { driver = { when_fact = 'still_here', label = 'driver', npc = { ... } }, }, outcomes = { unable_to_locate = { order = 1, quality = 'good', label = 'Unable to locate', requires = 'not fact.still_here and report.filed', }, -- ... and the endings for when it IS there } - Order them from worst to best, or from most specific to most general. “They got away” has to beat “cited and released”, or a call where somebody ran closes as a routine citation because the report happened to be filed.
- Give every ending a way to happen. A callout with one outcome ends the same way every time and the validator warns about it. Two or three acceptable endings is what makes a call worth running twice.
Locations
Twelve sets ship, and a callout usually just names one:
| Set | Points | What it is for |
|---|---|---|
retail | 6 | Stores and businesses — shoplifting, robberies, disturbances. |
roadway | 6 | Surface streets with a shoulder, on purpose: a traffic stop in an intersection is a scene where nobody can park and the tow cannot work. |
highway | 8 | The freeway calls, where closing a lane is a real objective. Separate from roadway deliberately: a crash with entrapment belongs where traffic is doing eighty, and nothing here sits in a gore point. |
residential | 5 | Houses, a trailer and an apartment — structure fires, disturbances, welfare checks. |
apartment | 8 | Multi-unit buildings — noise, disputes, welfare checks, a fire with neighbors either side. |
commercial | 8 | Alarms, trespass, cold reports and the big fire. Big park arrays: a commercial fire parks four apparatus. |
industrial | 8 | Separate from commercial because the hazards differ in kind: a strip-mall fire is a building, and a fire at a tank farm is a building plus whatever is stored next to it. A callout that does not care names both. |
nightlife | 8 | The 2am calls — assaults, disorderly subjects, noise. Points are outside the door, which is both where the fight usually is and where an ambulance can reach it. |
gasstation | 6 | Robbery alarms, spills, a vehicle fire under a canopy. Its own set rather than part of retail, because the canopy is a hazard. |
parking | 8 | Vehicle burglary, abandoned vehicles, lot disputes. |
openground | 5 | Brush, lots and alleys — dumpster and vehicle fires, public assists. Every one within sight of a road, because a fire a crew cannot park near is a fire nobody fights. |
wildland | 8 | Brush fires, and nothing else. Chosen for engine access above everything: every point is within fifty meters of a road a brush truck can actually drive. |
A callout can pool several sets, which is usually the right answer for a call that happens in more than one kind of place:
locations = { 'apartment', 'residential' },
Registering your own
A file anywhere under locations/, globbed exactly like
scenarios/:
Locations.Register('docks', {
{
coords = { x = 1275.4, y = -1710.6, z = 54.8 },
heading = 190.0, -- where a responder faces on arrival
label = 'the warehouse on Miriam', -- what dispatch calls the place
interior = false, -- the scene is inside a building
tags = { 'industrial', 'city' },
-- Where an NPC unit appears, out of sight, 150-250 m out. It then
-- drives only this one leg.
approach = { x = 1130.2, y = -1580.4, z = 35.0, h = 200.0 },
-- Curb slots, in preference order. An NPC unit parks here and walks
-- the last twenty meters.
park = { { x = 1262.8, y = -1724.2, z = 54.6, h = 10.0 } },
},
})
Only coords is required; everything else makes the scene
tidier. Writing a park slot is five seconds of work that removes most
NPC pathfinding trouble before it can start, and the validator warns
you about a callout that asks for a tow truck while its location set has
points with nowhere to park.
You do not have to type any of those numbers. Stand where the scene should
be, face the way a responder should face, and run /psspoint the
warehouse on Miriam — it prints that whole table, with a curb slot,
to the F8 console. /psspoint approach and /psspoint
hydrant print one line each from where you are standing. And once a set
is in the catalog, /pssadmin will walk you through its points and
let you move the ones that are wrong. See
Checking and fixing locations.
A callout can also carry its own list inline, in the same shape, which is right for a one-off tied to a specific building:
locations = {
{ coords = { x = 1275.4, y = -1710.6, z = 54.8 }, heading = 190.0,
label = 'the warehouse on Miriam' },
}
Points cool down for fifteen minutes after being used, so a set with eight points is eight different evenings rather than the same driveway twice.
Writing a fire callout
Add a fire block. Everything else about the callout is the
same.
fire = {
seats = 8, -- how many burning spots. The cap is 24.
lit = 3, -- how many are already burning when the crew arrives
spread = 6.0, -- how far apart the seats are laid out, in meters
intensity = 1.2, -- above 1.0: each seat takes longer to knock down
group = 'interior', -- a name `ignite()` and your rules can refer to
search = true, -- open the primary-search objective with the call
}
Two objectives are opened for you: knock down the fire
always, and primary search unless you set
search = false. objective.knockdown.done is therefore
the condition most fire outcomes are written against.
When the callout knows the building, place the seats by hand instead and the fire is laid out in the rooms you meant:
fire = {
seats = 4,
offsets = {
{ x = 0.0, y = 0.0, z = 0.0, group = 'kitchen', intensity = 1.4 },
{ x = 2.5, y = 1.0, z = 0.0, group = 'kitchen' },
{ x = -3.0, y = 2.0, z = 0.0, group = 'hallway' },
{ x = -3.0, y = 6.0, z = 3.0, group = 'upstairs' },
},
}
Three things to get right:
- List
fireas a service. A callout with a fire block that does not page the fire department is a building burning down with nobody told about it, and the validator warns about exactly that. - Let it spread. A fire that cannot grow is a prop. The engine spreads it for you; your job is to give it enough seats to spread into, and a rule that warns the crew first.
- Do not make the ending depend on medical care. This
build models NPC patients only. A victim who is removed to the exterior is
rescued, the objective completes, and the NPC ambulance handles the rest.
Write the outcome against
role.occupant.rescued.
Asking for an NPC service
Declare what the callout may ask for, then ask:
aux = { 'ems', 'coroner', 'tow' },
rules = {
{ when = 'fact.victim_found',
then_ = 'page(ems) + request_aux(ems)' },
{ when = 'fact.fatality',
then_ = 'request_aux(coroner)' },
}
Note that rule asking for both: page(ems)
tells every real medic on duty, and request_aux(ems) arranges the
NPC ambulance. That is not belt and braces — the NPC service only comes
when nobody is covering the job, and a medic who attaches while the NPC
ambulance is still driving turns it around. Writing both means the call closes
whether or not anybody is playing EMS tonight, which is the entire reason the
NPC services exist.
Five ship:
ems | Transports a viable patient, or pronounces one who is not and calls the coroner. |
coroner | The medical examiner, for a body. |
tow | The flatbed. Needs a curb slot at the location, and the validator warns when the set has points without one. |
transport | The prisoner van, for somebody cuffed whose arresting officer would rather keep working than drive to jail. Sets prisoners_transported. |
taxi | A cab for the people who are not under arrest. Ask for one in any callout that ends with somebody standing in the road — a towed motorist, a sober passenger, a witness who has given a statement. |
animal, mutualaid and roadcrew are
named but ship off: asking for one validates with a warning and is
refused at runtime, so a callout written today against a service that is coming
later still loads and still works.
Whether a request produces anything is the server owner's setting, not yours: each service is on request, when nobody is available, always, or never. Write the callout so it closes either way.
Worked example: a two-role police call
Two people, two very different jobs. The clerk has information; the suspect
has a story. A unit who talks to the clerk first finds out it was a shove
rather than a grab, which changes what the whole call is. This is
scenarios/police/retail_theft.lua, shortened here — the real
file has a few more rules and its outcomes written out.
Scenario.Register({
id = 'retail_theft',
title = 'Shoplifter in custody',
service = 'police',
priority = 3,
weight = 28,
units = { min = 1, ideal = 2 },
locations = 'retail',
roles = {
suspect = {
label = 'suspect',
npc = { models = { 'a_m_y_methhead_01', 'a_f_y_hipster_02', 'a_m_y_downtown_01' } },
offset = { x = 1.2, y = 1.0, z = 0.0 },
facts = { 'suspect_name', 'warrant_active', 'suspect_armed', 'contraband_pills' },
onId = { 'warrant_active' },
onInterview = { 'suspect_name' },
onSearch = { 'suspect_armed', 'contraband_pills' },
disposition = { compliance = 0.55, flight = 0.3, violence = 0.05, honesty = 0.35 },
},
clerk = {
label = 'store clerk',
npc = { models = { 'mp_m_shopkeep_01', 'a_f_m_soucent_01' } },
offset = { x = -1.4, y = 1.2, z = 0.0 },
-- The clerk knows what happened and will say so: high honesty, and
-- their facts come out in an interview rather than a search.
facts = { 'stolen_value', 'has_camera_footage', 'suspect_was_violent' },
onInterview = { 'stolen_value', 'has_camera_footage', 'suspect_was_violent' },
disposition = { compliance = 0.95, flight = 0.0, violence = 0.0, honesty = 0.95 },
},
},
facts = {
suspect_name = true,
stolen_value = { roll = { 15, 420 } },
has_camera_footage = { chance = 0.7, hidden = true },
suspect_was_violent = { chance = 0.15, hidden = true },
warrant_active = { chance = 0.18, hidden = true },
suspect_armed = { chance = 0.07, hidden = true },
contraband_pills = { chance = 0.15, hidden = true },
},
rules = {
{ when = 'known.suspect_was_violent',
then_ = 'escalate(1) + disposition(suspect, "violence", 0.25) + say("the clerk says they were shoved")' },
{ when = 'known.suspect_armed',
then_ = 'escalate(2) + disposition(suspect, "violence", 0.3)' },
},
outcomes = { ... },
})
What to take from it
- Two roles, two revelation paths. The clerk's facts are
all
onInterview; the suspect's are split acrossonId,onInterviewandonSearch. Same fact board, and which verb you reach for decides what you learn. - The dispositions carry the characterization. Nothing in this file says “the clerk is helpful”. Compliance 0.95 and honesty 0.95 says it, and says it in a way the engine can act on.
- A roll, not a coin flip.
stolen_valueis a number between 15 and 420, which makes the same callout a misdemeanor one night and something else the next, with no extra rules. - Rules on
known., notfact.. The suspect does not get twitchy because they were violent; they get twitchy because the officer found out. - A fact nobody ever finds is a real result. Seven percent of the time there is a gun, and a crew who never searches never knows. The report tells them afterward that it was there, which is the most educational line the resource prints.
Worked example: a multi-service fire
The deep end, and the callout the engine was built to prove: it pages two
services, carries a victim, spreads if it is ignored, and can end five
different ways depending on what the crew actually does. This is
scenarios/fire/structure_fire.lua.
Scenario.Register({
id = 'structure_fire',
title = 'Structure fire',
service = { 'fire', 'police' }, -- fire works it, police holds the street
priority = 1,
weight = 16,
units = { min = 1, ideal = 4 },
locations = 'residential',
fire = {
seats = 8,
lit = 3,
spread = 6.0,
intensity = 1.2,
group = 'interior',
search = true,
},
roles = {
caller = {
label = 'the homeowner',
npc = { models = { 'a_f_m_soucent_02', 'a_m_m_soucent_01' } },
offset = { x = -7.0, y = -5.0, z = 0.0 }, -- out front, where they would be
facts = { 'caller_name', 'occupants_unaccounted', 'structure_occupied', 'pets_inside' },
onInterview = { 'caller_name', 'occupants_unaccounted', 'structure_occupied', 'pets_inside' },
disposition = { compliance = 0.8, flight = 0.1, violence = 0.0, honesty = 0.95 },
},
occupant = {
label = 'occupant',
victim = true, -- what the primary search looks for
npc = { models = { 'a_m_y_genstreet_01', 'a_f_y_genhot_01' } },
offset = { x = 2.0, y = 3.0, z = 0.0 },
idle = 'victim',
facts = { 'occupant_found' },
disposition = { compliance = 0.9, flight = 0.0, violence = 0.0, honesty = 0.9 },
},
},
facts = {
caller_name = true,
-- The fact the whole call turns on, and it is HIDDEN: nobody tells you
-- there is somebody inside. You have to ask the person standing in the
-- front yard, which is the thing crews forget to do.
occupants_unaccounted = { chance = 0.65, hidden = true },
structure_occupied = true,
pets_inside = { chance = 0.3, hidden = true },
-- Declared false so the rules and outcomes below may read them. The
-- verbs set them.
occupant_found = false,
primary_search_done = false,
interior_unsafe = false,
vented = false,
entry_made = false,
sized_up = false,
victim_found = false,
fatality = false,
body_removed = false,
},
rules = {
-- Ask the homeowner and the search becomes urgent. The single most
-- important rule in the catalog: information changes the job.
{ when = 'known.occupants_unaccounted',
then_ = 'escalate(1) + objective("primary_search", "Somebody is unaccounted for -- search it") + say("the homeowner says somebody is still inside")' },
-- A size-up before anybody commits is what a good crew does, and the
-- scene notices.
{ when = 'fact.sized_up and scene.fire > 1',
then_ = 'say("good size-up, and a crew to work it")' },
-- Left to burn, it takes the building. The crew is warned first.
{ when = 'scene.fires_burning > 5 and not fact.vented',
then_ = 'say("heavy smoke pushing from the eaves -- it needs venting")' },
{ when = 'fact.interior_unsafe and not fact.vented',
then_ = 'escalate(1) + say("conditions inside are deteriorating")' },
-- Found somebody. Page EMS whether or not any medics are on.
{ when = 'fact.victim_found',
then_ = 'page(ems) + request_aux(ems)' },
-- Nobody went in, and the fire took the room they were in.
{ when = 'known.occupants_unaccounted and scene.age > 540 and not role.occupant.rescued',
then_ = 'set(fatality, true) + request_aux(coroner) + say("it has been too long")' },
-- Police on scene get something to do that is actually their job.
{ when = 'scene.police > 0',
then_ = 'objective("traffic", "Keep the street clear for apparatus")' },
},
outcomes = {
rescue_and_knockdown = {
order = 1, quality = 'good', label = 'Occupant out, fire knocked down',
requires = 'role.occupant.rescued and objective.knockdown.done',
},
rescue_made = {
order = 2, quality = 'good', label = 'Occupant removed',
requires = 'role.occupant.rescued and report.filed',
},
fatality_recovered = {
order = 3, quality = 'poor', label = 'Fatality',
requires = 'fact.fatality and fact.body_removed',
},
knockdown_no_search = {
order = 4, quality = 'acceptable', label = 'Fire out, nobody searched',
requires = 'objective.knockdown.done and not fact.primary_search_done',
},
knocked_down = {
order = 5, quality = 'acceptable', label = 'Fire knocked down',
requires = 'objective.knockdown.done',
},
},
aux = { 'ems', 'coroner' },
})
What to take from it
- The hidden fact is the whole call. Two thirds of the time somebody is inside, and the only way to find out is to talk to the homeowner standing in the front yard. A crew who goes straight to the fire puts it out, closes with Fire out, nobody searched, and reads in the report that there was a person in the back bedroom. Nobody forgets the second one.
- Two services, each with real work. Fire fights it; police get a traffic objective the moment one of them shows up. Neither is standing around watching the other.
- A clock, used once.
scene.age > 540is the only timer in the file, and it exists so that ignoring the call has a consequence rather than so that the call has a script. - Five outcomes, strictly ordered. A rescue beats a knockdown; a fatality beats a tidy knockdown; “fire out, nobody searched” is called what it is. The order is what makes the report honest when three of them are true at once.
- The facts the verbs own are declared
false.sized_up,vented,victim_foundand the rest are set by the fire verbs — but a rule may not read an undeclared fact, so they are listed. This is the step people miss and the validator catches. - Nothing in it depends on a drive succeeding. The ambulance is requested, not required. If it cannot get there, the call still closes and the report still says what happened.
Checking your work
From a terminal, in a second
python tools/validate.py # load everything, report, non-zero on a refusal
python tools/validate.py --quiet # only problems
python tools/validate.py --twice # prove a double load is harmless
This is not a second implementation that might disagree with the server: it
loads shared/expr.lua and server/registry.lua
unmodified and runs the server's own validator against the
same files in the same order. A callout that passes here passes in game. It
needs lupa (pip install lupa); everything FiveM
provides is stubbed.
From the server console
The boot report names what loaded, where it came from, and what it refused. The second line is the one that answers “did it see my file?” without anybody turning debug on:
[sals_kewlpublicsafetysimulator] catalog: 24 scenarios, 12 location sets (84 points)
[sals_kewlpublicsafetysimulator] loaded from: scenarios/ 24
And a refusal is loud, by name, with the file and the reason — while the rest of the catalog still loads:
scenario "my_callout" in scenarios/police/mine.lua:9 was REFUSED and is not loaded:
- rule 2 `when` reads "fact.armed", but this scenario never declares a fact
called "armed". An undeclared fact is never true, so this condition would
never fire.
/pssdiag prints the same information in game, including every
refusal and why. /pssadmin lists the catalog with each callout's
file, rule count and outcome count.
A file that does not appear at all, with no refusal, did not load as Lua — a missing comma or an unclosed brace. That error prints further up the console, before anything of ours runs, because a file that fails to parse never reaches us to be refused.
Testing the behavior, not just the shape
/pssadminopens a specific callout on demand, so you are not waiting on the Director./pssdebug scenesshows the live fact board, every role's flags and dispositions, which rules have fired, and the fire seats with their water./pssdebug verbsshows what was refused and why, which is the fastest way to find a verb gated behind something you forgot.- Raise a
chanceto1.0while you are testing the branch it guards. Nothing is more tedious than re-running a callout nine times to see the one-in-ten path.
Exports, for tooling of your own
-- Validate a table before you write it to a file. Returns ok, errors, warnings.
local ok, errs, warns = exports['sals_kewlpublicsafetysimulator']:ValidateScenario(spec)
-- The catalog as loaded, and the action catalog with its help text.
local catalog = exports['sals_kewlpublicsafetysimulator']:GetScenarios()
local actions = exports['sals_kewlpublicsafetysimulator']:GetActionCatalog()
The validator is pure — it reads the location sets and the action catalog and changes nothing — which is what makes it safe to run against a file somebody just downloaded, or against the output of a generator, before anybody is told it worked.
Overriding one of ours, and sharing
Change one of ours without losing it at the next update
Copy the file, keep the id, change what you like:
scenarios/police/dui.lua <- ours, replaced on every update
scenarios/police/dui-mine.lua <- yours, same id, wins
A later registration of the same id replaces the earlier one, and the boot log says so:
scenario "dui" replaced by scenarios/police/dui-mine.lua:9 (it was
scenarios/police/dui.lua). The later registration wins -- this is how you
override one of ours.
Editing our file directly works too, and is lost the next time you take an update, because that file has our name on it and the download carries it.
Turning one of ours off
Delete the file, or set its weight somewhere it will never come up by
re-registering the id with weight = 0.01. (Zero is refused: a
callout that can never be chosen is more likely to be a mistake than an
intention.)
Sharing a callout
Post the file. That is the whole procedure, and it is why callouts are data: a file from a stranger is a table and a handful of expressions, it cannot run code, and if it is malformed the server that loads it says so by name instead of breaking.
Three courtesies for a file you intend other people to use:
- Put
authorandversionin the table. They show up in the catalog listing. - Use a shipped location set, or ship your set in the same file. A callout naming a set nobody else has is refused on their server.
- Write a comment block at the top saying what the call is and what makes it move. Everybody reading it will be reading it to learn how to write their own.
Reference tables
Services, priorities, qualities
| Service | police, fire, ems |
|---|---|
| Priority | 1 emergency · 2 urgent · 3 routine · 4 public assist |
| Quality | good, acceptable, poor |
| NPC services | Shipped: ems, coroner, tow, transport (the prisoner van), taxi. Named but off in this build: animal, mutualaid, roadcrew. |
| Unit status | En Route · On Scene · Clear |
| Scene state | pending · active · staged · closing · closed · expired |
What each verb sets
| Verb | Service | Sets or reveals | Needs first |
|---|---|---|---|
| (a question) | police | spoken_to; reveals what that answer gives away, or lies about it | whatever the question's requires says |
| ask_id | police | identified; reveals <role>_name with the name on it, and files the licence in the case file | their compliance |
| run_person | police | reveals onId facts; files the licence in the case file | identified |
| run_plate | police | reveals plate_stolen, vehicle_unregistered, vehicle_wanted | — |
| search_person | police | searched; reveals onSearch facts | detained, cuffed, arrested or down |
| search_vehicle | police | vehicle_searched; reveals vehicle_* and contraband facts | — |
| fst_hgn | police | scores the eye test | spoken_to, not restrained, and they agree |
| fst_walk | police | scores walk and turn | the eye test |
| fst_balance | police | scores the one-leg stand, and on the third test sets tested and reveals intoxicated | walk and turn |
| breath_test | police | tested; reveals bac, intoxicated | a breathalyzer, if your inventory has one |
| detain / hands_up / release | police | detained, or a refusal | an answer from them |
| cuff | police | restrained, arrested | detained |
| uncuff | police | clears both | restrained |
| escort | police | escorted | restrained, and not already in a unit |
| release_hold | police | clears escorted | escorted |
| put_in_vehicle | police | in_vehicle; clears escorted | restrained, and you standing at a vehicle |
| transport | police | transported | restrained and in_vehicle |
| statement | police | statement_taken | spoken_to |
| photograph | police | scene_photographed | — |
| seize | police | property_seized | searched |
| file_report | police | report.filed | — |
| size_up | fire | sized_up; reveals structure_*, occupants_unaccounted | nobody has given one yet |
| force_entry | fire | entry_made | — |
| search_primary | fire | primary_search_done, victim_found, <role>_found; opens a removal objective; pages EMS | a tenable interior, or a vent |
| vent | fire | vented | — |
| rescue | fire | rescued | the role is a victim |
| traffic_control | any | traffic_controlled | a roadway to control |
| mark_deceased | fire, EMS | deceased, pronounced, fatality; calls the coroner | — |
A callout skeleton to copy
Scenario.Register({
id = '', title = '', service = 'police',
priority = 3, weight = 20, units = { min = 1, ideal = 2 },
tags = { }, locations = 'roadway',
as_reported = { '' },
roles = {
someone = {
label = '', npc = { models = { '' } },
offset = { x = 0.0, y = 0.0, z = 0.0 },
facts = { }, onId = { }, onInterview = { }, onSearch = { },
disposition = { compliance = 0.7, flight = 0.1, violence = 0.02, honesty = 0.6 },
},
},
facts = { },
rules = { { when = '', then_ = '' } },
outcomes = { done = { order = 1, quality = 'acceptable', label = '', requires = '' } },
})
Support
Discord: discord.gg/CVWZb6AEwy
Documentation: salskewlkorner.com/documentation/
When reporting a problem, paste the output of /pssdiag. It
answers most questions on its own: what was detected, what the catalog loaded,
what it refused and why, what you have changed from the defaults, and what the
resource has actually done.
If you write a callout you are pleased with, post it. A good one teaches more about this resource than this manual does.