# Server-owned NPCs

Create server-owned NPCs and control their identity, routing bucket, health, tasks and simulation authority. Clients project nearby NPCs into the world but cannot create or mutate their canonical state.

The reference implementation is [`resources/system/open77_npcs`](https://github.com/Open2077/open77-resources/blob/main/system/open77_npcs/README.md).

Spawn directly from `Character.*` record IDs, without registering a
virtual template. Browse the [NPC record catalogue](npc-catalogue.md) for IDs, appearances
and compatibility warnings. See [AI, combat and voice control](npc-behavior.md) to make a
hostile NPC passive, disable its perception or silence combat/search voice lines.

## Manifest permissions

Server mutation requires `world.npcs`. Client inspection requires `npcs.read`.

```lua
permissions { "world.npcs", "npcs.read" }
```

## Architecture

- NPC IDs are opaque, generation-aware 64-bit server IDs.
- The server streams NPCs only to players in the same routing bucket and inside the configured
  radius. Hysteresis prevents rapid stream-in/stream-out near the boundary.
- One ready client receives a short simulation lease and executes native REDengine commands. The
  lease carries an epoch; reports from an old owner or epoch are rejected.
- Other clients interpolate authoritative motion and never run a competing task.
- Late joiners receive the complete NPC state and active tasks.
- Movement, look, action and full-body channels execute independently. Starting a look-at does not
  cancel movement, and starting movement does not stop an active full-body animation.
- A resource may inspect and mutate only NPCs it owns.
- Stopping a resource removes its non-persistent NPCs. Persistent NPCs must be removed explicitly
  or restored/managed by server code after a resource restart.

## Character records and legacy aliases

Spawn directly with `Open77.npcs.create({ record = "Character.Judy", position = ... })`.
No catalogue registration is required. Vanilla, DLC and custom `Character.*` records use the
same authoritative lifecycle and replication path. Their record and required assets must exist
on every observing client; the headless server cannot inspect a player's TweakDB.

Use exact, case-sensitive textual IDs (not hashes or `.ent` paths): `Character.` followed by
ASCII letters, digits, `_`, `-` or `.`, at most 255 bytes in total. `record` takes precedence over
`template`; an invalid explicit record is rejected, never replaced with a default NPC.
For compatibility, `template = "Character.Judy"` and the first argument of the low-level
`CreateNpc` (with its existing positional arguments) also accept records. Existing aliases and
the old default when **both fields are absent** remain available.
`Open77.npcs.templates()` lists these optional legacy aliases, not all spawnable characters:

```lua
for _, template in ipairs(Open77.npcs.templates()) do
    print(template.name, template.record)
end
```

| Alias | Faction | Intended use |
|---|---|---|
| `civilian_female_relaxed_01` | Aldecaldos | Relaxed civilian projection and scripted locomotion. **Not shootable** — see the warning below. |
| `hostile_female_ranged_lab` | Maelstrom | Ranged gang combatant. |
| `gang_valentinos_ranged_01` | Valentinos | Ranged gang combatant. |
| `gang_tygerclaws_ranged_01` | Tyger Claws | Ranged gang combatant. |

An alias contains its predefined record, observer record and capability list. An advertised
capability describes the underlying rig/template; it does not make an unstable task public.

`civilian_female_relaxed_01` resolves to `Character.Panam`, whose `Invulnerable` tag prevents normal combat targeting and damage. Do not arm this template: its weapon graph can crash the client. Use it only as a passive background character; use combat-compatible records for damageable NPCs.

**Native combat relations are explicit.** A native gang record runs engine combat AI on
every client streaming it. For `aiMode = Open77.npcs.ai.native`, the client makes native
NPCs hostile to the local player and, by default, to other native NPCs in their bucket.
Choosing the same gang template does not make a team. Idle NPCs acquire the nearest
hostile NPC within the existing combat radius; default player acquisition uses engine
perception.

Resources may opt into a shared team and nearby player acquisition through loadout metadata:

```lua
loadout = {
    combat = { group = "my_resource_guards", acquirePlayers = true },
}
```

Equal nonempty `combat.group` values make native NPC pairs friendly and exclude them
from each other's automatic target selection. Groups are case-sensitive, at most 64
ASCII letters, digits, `_`, `-` or `.`; prefix them with the resource name to avoid
collisions in a shared bucket. Missing or invalid groups preserve free-for-all behavior.
This controls engine relations, not a server damage arbiter: a gamemode should enforce
its friendly-fire rules in damage handling too.

`acquirePlayers` defaults to `false`. When explicitly `true`, an idle NPC can acquire a
living local player in the same bucket within 30 metres, provided a chest-height
Static/Dynamic world raycast is clear and no closer hostile NPC was selected. The engine
still decides movement and firing. This is a perception aid, not an AI task or a damage
grant; gamemode shields and damage validation remain authoritative.

Metadata without a top-level `weapon` never enters the player-proxy equipment path.
Gang records retain their native inventory. A nested `combat.weapon` is ignored.
Deploy a client containing this policy before enabling cooperative camps: older clients
ignore the metadata and retain the default hostility sweep.

### Complete 2.31 research catalogue

The research catalogue helps find records and appearances for Cyberpunk 2077 2.31 + Phantom Liberty.
It does not gate the runtime API:

- [6,668 `Character.*` records](../docs/generated/npc-records-2.31.csv), including template,
  gameplay metadata, crowd appearances and structural risk classification;
- [1,524 unique `.ent` templates](../docs/generated/npc-templates-2.31.md), all verified present and
  readable in the installed base-game/EP1 archives;
- [all `.ent` appearance bindings](../docs/generated/npc-entity-appearances-2.31.csv);
- [all definitions from the referenced `.app` resources](../docs/generated/npc-appearance-resources-2.31.csv);
- [detailed machine-readable `.ent`/`.app` graph](../docs/generated/npc-entity-appearances-2.31.json).

The `category` and `risk` columns are research filters, not a runtime allowlist. In
particular, `candidate` means only that no obvious quest/player/vendor/special-rig blocker was found
in the extracted fields. Access to a record does not guarantee that its quest logic, special rig,
animations, appearance or equipment supports every NPC task. Test the records you ship; prefer
native inventory over forcing player-proxy equipment onto an unrelated character graph.

See the [methodology, source hashes, limitations and validation backlog](../docs/research/npc-templates-and-appearances-catalog.md).

## Create and inspect

```lua
local npcId, reason = Open77.npcs.create({
    record = "Character.Judy",
    position = { x = -1378.0, y = 1262.0, z = 123.0 },
    yaw = 90.0,
    bucket = 0,
    appearance = nil,
    loadout = {},
    aiMode = Open77.npcs.ai.tasks,
    damagePolicy = Open77.npcs.damage.immortal,
    health = 100,
    maxHealth = 100,
    streamingRadius = 180,
    streamingHysteresis = 40,
    despawnWhenUnobserved = false,
    persistent = false,
})

if not npcId then error(reason) end

local npc = Open77.npcs.get(npcId)
local owned = Open77.npcs.all()       -- this resource only
local inBucket = Open77.npcs.all(12)  -- optional bucket filter
```

`get` returns the template, appearance, loadout JSON, position, bucket, streaming values, health,
flags, AI/damage modes, revisions, active task and current authority lease.

Limits are enforced server-side: 4096 NPCs globally, 512 per resource and 64 tasks per NPC.

### Server API reference

All functions in this table require `world.npcs`. IDs are opaque 64-bit Lua integers. Mutation
functions return `true` only when the NPC exists and belongs to the calling resource. Invalid
task parameters, IDs or enum values raise a Lua error; callers should treat these as resource
bugs rather than normal gameplay failures.

`create` is the exception: an invalid record ID, unknown alias or out-of-range field is a **normal
rejection**, not an error. It returns `nil, reason` — for example
`nil, "npc_record_invalid"` or `nil, "npc_template_not_found"` — so always check both return values.
A returned ID confirms canonical server creation, **not** successful engine spawning. Client-side
record lookup and asynchronous spawning can still fail; use `onNpcReady` and `onNpcSpawnFailed`.

| Function | Parameters | Return |
|---|---|---|
| `Open77.npcs.create` | `definition` | `npcId`, or `nil, reason` when the subsystem/permission is unavailable |
| `Open77.npcs.get` | `npcId` | owned NPC snapshot or `nil` |
| `Open77.npcs.all` | optional `bucket` | array of owned NPC snapshots |
| `Open77.npcs.templates` | none | array of optional legacy alias snapshots (not an exhaustive catalogue) |
| `Open77.npcs.update` | `npcId, fields` | boolean |
| `Open77.npcs.setTransform` | `npcId, transform` | boolean |
| `Open77.npcs.setBucket` | `npcId, bucket` | boolean |
| `Open77.npcs.setAppearance` | `npcId, appearance` | boolean |
| `Open77.npcs.setLoadout` | `npcId, loadout` | boolean |
| `Open77.npcs.setEquipment` | `npcId, { slot = "Items.*", ... }` | `true`, or `nil, reason` |
| `Open77.npcs.getEquipment` | `npcId` | slot-to-record table, or `nil` |
| `Open77.npcs.speak` | `npcId, voice, optional options` | `true`, or `nil, reason` |
| `Open77.npcs.voices` | none | array of `{ name, context, source }` |
| `Open77.npcs.setHealth` | `npcId, health, optional maxHealth` | boolean |
| `Open77.npcs.setDamagePolicy` | `npcId, policy` | boolean |
| `Open77.npcs.setAiMode` | `npcId, mode` | boolean |
| `Open77.npcs.setRagdoll` | `npcId, enabled` | boolean |
| `Open77.npcs.setAttitude` | `npcId, attitude, optional options` | boolean, or `false, reason` |
| `Open77.npcs.getAttitude` | `npcId` | attitude table or `nil` |
| `Open77.npcs.setGroup` | `npcId, group` | boolean, or `false, reason` |
| `Open77.npcs.getGroup` | `npcId` | group string or `nil` |
| `Open77.npcs.setRelationship` | `groupA, groupB, attitude` | boolean, or `false, reason` |
| `Open77.npcs.getRelationship` | `groupA, groupB` | attitude string or `nil` |
| `Open77.npcs.target` | `npcId` | `{ kind, id }` or `nil` |
| `Open77.npcs.applyDamage` | `npcId, amount, optional source, optional cause` | boolean |
| `Open77.npcs.kill` | `npcId, optional reason` | boolean |
| `Open77.npcs.revive` | `npcId, optional health` | boolean |
| `Open77.npcs.remove` | `npcId` | boolean |
| `Open77.npcs.presence` | `npcId` | `{ id, resource, owned, bucket, x, y, z, alive, authorityPlayerId, epoch, leaseMs? }`, or `nil, reason` |

`presence` is the single, constant-time read a rule needs when a client acts **for** an NPC:
who simulates it (`authorityPlayerId`, `0` when nobody; `epoch`; `leaseMs` only while a lease is
live), whether it is `alive` (alive flag and health above zero), and its canonical place and
bucket. `owned` says whether the caller created it and `resource` names who did. Unlike `get`, it
answers for another resource's NPC too when the caller also declares `npcs.foreign` (otherwise
`nil, "permission_denied:npcs.foreign"`), exactly like `all({ includeForeign = true })`. Other
reasons: `invalid_npc_id`, `npc_not_found`, `npcs_unavailable`. The
[door service](doors.md#npc-passage) uses it to let the simulating client open a door for its NPC.

```lua
-- open77.lua: permissions { "world.npcs", "npcs.foreign" }
-- Let a client speak for an NPC only while it simulates it, near the target.
local function speaksFor(player, npcId, target)
    local p = Open77.npcs.presence(npcId)
    if not p or not p.alive or p.authorityPlayerId ~= player or not p.leaseMs then return false end
    local dx, dy, dz = p.x - target.x, p.y - target.y, p.z - target.z
    return dx * dx + dy * dy + dz * dz <= 36
end
```

An NPC snapshot contains:

| Field | Type | Meaning |
|---|---|---|
| `id`, `revision`, `taskRevision` | integer | Canonical identity and monotonic revisions. |
| `resource` | string | Owning resource. |
| `template`, `record`, `observerRecord` | string | Legacy alias, or `"record"` for direct spawning. Both record fields contain the exact Character ID for direct spawns. |
| `appearance`, `loadout` | string | Appearance name and canonical loadout JSON. |
| `x`, `y`, `z`, `yaw` | number | Canonical transform. |
| `bucket` | integer | Routing bucket. |
| `streamingRadius`, `streamingHysteresis` | number | Interest thresholds in metres. |
| `health`, `maxHealth` | number | Canonical health. |
| `flags`, `aiMode`, `damagePolicy` | integer | Values from the constant tables below. |
| `currentTaskId` | integer | Preferred active task for compact replication, or `0`. |
| `authorityPlayerId`, `authorityEpoch` | integer | Current simulation lease owner and epoch, or `0`. |

A template snapshot contains `name`, `record`, `observerRecord`, `defaultAppearance` and a
`capabilities` array.

## State mutation

```lua
Open77.npcs.setTransform(npcId, { position = { x = 1, y = 2, z = 3 }, yaw = 180 })
Open77.npcs.setBucket(npcId, 7)
Open77.npcs.setAppearance(npcId, "appearance_name")
Open77.npcs.setLoadout(npcId, { weapon = "Items.Preset_Lexington_Default" })
Open77.npcs.setHealth(npcId, 80, 100)
Open77.npcs.setDamagePolicy(npcId, Open77.npcs.damage.mortal)
Open77.npcs.setAiMode(npcId, Open77.npcs.ai.tasks)
Open77.npcs.setRagdoll(npcId, true)
Open77.npcs.remove(npcId)
```

`Open77.npcs.update(id, fields)` can atomically change appearance, loadout, AI mode, damage policy,
health, maximum health and ragdoll, and takes an `equipment` table (below). The named setters
above are convenience wrappers.

Moving an NPC between buckets or teleporting it revokes the current simulation lease before the
new state is broadcast.

## Equipment: dressing an NPC

`Open77.npcs.setEquipment(id, set)` dresses an NPC in `Items.*` clothing records, on every
client that streams it, through the same puppet equipment path the loadout's `weapon` key
already uses. It is the server-side counterpart of FiveM's `SetPedComponentVariation` on a
ped — a uniform for a job NPC, a costume for an event.

```lua
-- A shopkeeper in a work jacket and jeans. The table is the WHOLE desired set.
local npc = Open77.npcs.create({ record = "Character.Judy", position = counter })
local ok, reason = Open77.npcs.setEquipment(npc, {
    OuterChest = "Items.Jacket_01_basic_01",
    Legs = "Items.Pants_01_basic_01",
    Feet = "Items.Shoes_01_basic_01",
})
if not ok then print("could not dress the clerk: " .. reason) end

-- Read it back; the same table also rides `json.decode(Open77.npcs.get(npc).loadout).equipment`.
local worn = Open77.npcs.getEquipment(npc)   --> { OuterChest = ..., Legs = ..., Feet = ... }

-- Swap one piece: send the set again with that slot changed. A slot you leave
-- out is a slot you want empty.
Open77.npcs.setEquipment(npc, { OuterChest = "Items.Jacket_02_basic_01", Legs = "Items.Pants_01_basic_01" })

-- Beside other fields, in one update.
Open77.npcs.update(npc, { equipment = { Head = "Items.Hat_01_basic_01" }, health = 100 })

-- Undress what you dressed.
Open77.npcs.setEquipment(npc, {})
```

Slots are the client's clothing vocabulary: `Head`, `Face`, `InnerChest`, `OuterChest`,
`Legs`, `Feet`, `Outfit`, `UnderwearTop`, `UnderwearBottom`. The held weapon is **not** a
clothing slot; it stays on `setLoadout(id, { weapon = ... })`. Values are `Items.*` record
names (`Items.` followed by an identifier, at most 200 characters); the catalogue of clothing
records is `docs/generated/clothing-2.31.csv`.

What the server promises, and refuses by name:

| Reason | Meaning |
|---|---|
| `npc_equipment_invalid` | The set is not a table (or, inside `update`, `equipment` is not one). |
| `npc_equipment_slot_invalid` | A key is not one of the nine clothing slots. |
| `npc_equipment_record_invalid` | A value is not an `Items.*` identifier of at most 200 characters. |
| `npc_not_found`, `npc_not_owned` | The id is unknown, or belongs to another resource. |

A refused set changes nothing. An accepted set is published as one revision, rides the NPC's
loadout envelope to every viewer and to late joiners, and survives a later `setLoadout` that
does not mention `equipment` — a resource re-sending a weapon does not strip the uniform. A
`setLoadout` that does state `equipment` (an empty table included) replaces it.

**What the client does with it.** Every viewer dresses its own puppet instance, one changed
slot at a time (`GiveItem` + `AddItemToSlot`, the transaction-system path a vanilla puppet
equips its own loadout with), and only ever empties a slot Open77 itself filled: a
character's own look lives in its appearance, not in its attachment slots, so nothing native
is stripped. The client never runs `InitializeSlots` for clothing — a `Character.*` record
declares its attachment slots in TweakDB (`Puppet_Base` declares thirty-three, the nine
clothing slots included) and the engine builds the container from that list at spawn;
re-initialising it would wipe the weapon the AI holstered. A slot the record does not declare
(a mech, an exo, a player puppet record) is refused on the client with a log line
`[npc] equipment refused ... reason=slot_unavailable`; a record whose list cannot be read gets
nothing (`slots_unknown`). Success is the log line
`[npc] equipment applied npc=<id> slot=<slot> record=<record>` per slot.

**Honest limits.** The server has no TweakDB: existence of the record, and whether the
garment mesh binds to that character's rig, are decided on each client against the live
game. Clothing records are authored for the player bodies; on a vanilla civilian body some
garments will fit and some will clip or float, and only a look at the screen tells which —
test the records you ship on the records you ship. There is no NPC-side equipment read on
the client Lua surface; `Open77.npcs.getEquipment` (server) and the client log are the
readbacks.

### Constants

```lua
Open77.npcs.flags.alive
Open77.npcs.flags.ragdoll
Open77.npcs.flags.despawnWhenUnobserved
Open77.npcs.flags.persistent
Open77.npcs.ai.tasks
Open77.npcs.ai.frozen
Open77.npcs.ai.native
Open77.npcs.damage.mortal
Open77.npcs.damage.immortal
Open77.npcs.damage.invulnerable
```

`tasks` is the stable default. `frozen` immediately revokes the simulation lease and suspends every
task channel; returning to `tasks` resumes them with fresh timeout/duration accounting. A dead NPC
cannot receive a lease, motion report or new task until it is revived. `native` is reserved for
templates whose autonomous REDengine behaviour has been explicitly validated.

## Tasks

For native AI pause, passive NPCs, sensory acquisition and per-NPC voice suppression, see
[NPC AI, combat and voice control](npc-behavior.md). These options are set with
`behavior = {...}` at creation or `Open77.npcs.setBehavior(id, {...})` later; snapshots expose
the current `behavior` table. They survive loadout changes and stream-out/stream-in.

Tasks are server queues partitioned into movement, look, action and full-body channels. Priority is
evaluated within a channel. One task may execute in each channel at the same time. A timeout of `0`
means no timeout. Task/channel combinations are validated server-side; for example `moveTo` is
movement-only, `lookAt` is look-only and `playAnimation` is full-body-only. `wait` may be assigned
to any channel and blocks only that channel.

| Helper | Channel | Behaviour |
|---|---:|---|
| `moveTo` | movement | Native navigation to one position. |
| `follow` | movement | Re-paths toward a player, NPC or fixed position. |
| `patrol` | movement | Sequences arbitrary positions, waits and optional loops. |
| `wander` | movement | Deterministic roaming around a centre. |
| `face` | look | Rotates the body toward a point. |
| `lookAt` | look | Continuously aims the look-at target at a player, NPC or point. |
| `wait` | action by default | Server-timed delay. |
| `hold` | movement | Holds the current position. |
| `playAnimation` | full body | Plays a reviewed named workspot animation. |
| `attack` | action | Engages a player or an owned NPC. Composes with a movement task. |
| `guard` | movement | Holds an area, and fights from it without being pulled home. |
| `flee` | movement | Breaks contact with a player, NPC or fixed point. |

### Move, follow and patrol

```lua
local move = Open77.npcs.tasks.moveTo(npcId, { x = 10, y = 20, z = 30 }, {
    speed = "walk", acceptanceRadius = 1.0, timeoutMs = 30000,
})

local follow = Open77.npcs.tasks.follow(npcId, { type = "player", id = playerId }, {
    speed = "run", distance = 2.0, onTargetLost = "wait",
})
local followNpc = Open77.npcs.tasks.follow(npcId, { type = "npc", id = otherNpcId })

local patrol = Open77.npcs.tasks.patrol(npcId, {
    { x = 10, y = 20, z = 30, waitMs = 500 },
    { x = 16, y = 20, z = 30, waitMs = 1000 },
}, { speed = "walk", loop = true, backAndForth = false })

local wander = Open77.npcs.tasks.wander(npcId, {
    x = 10, y = 20, z = 30, radius = 15, speed = "walk", seed = 42,
})
```

`walk`, `run` and `sprint` are supported movement speeds. Patrol accepts 1 to 64 points. The
current `onTargetLost` policy waits for the target to become available again.

A walk never arrives by teleport. Before a `moveTo` starts, the client simulating the NPC asks the
AI navigation for a path. A destination it cannot reach (a locked door in the way, a wall, a point
off the walkable mesh) is not handed to the engine as is: the NPC walks to the closest reachable
point, asks a managed door in front of it to open ([NPC passage](doors.md#npc-passage)), and
re-checks once a second. If the way opens it walks on; if not, after about five seconds the task
ends with `failure` and reason `path_blocked`. Standing within `acceptanceRadius` + 0.5 m of an
unreachable point counts as arrived: `success`, reason `nearest_reachable`.

| Task end reason | Meaning |
|---|---|
| `path_blocked` | The destination stayed unreachable (the NPC stopped where the path ends), the engine tried to place the body instead of walking it three times, or a `moveTo` made no progress for five seconds three times. |
| `nearest_reachable` | Success next to a destination that cannot itself be stood on. |
| `report_jump` | The server refused the position the simulator reported for the task's end (the NPC could not have walked there); the task ends where the server last saw the NPC. |

```lua
local move = Open77.npcs.tasks.moveTo(npcId, { x = 10, y = 20, z = 30 }, { speed = "walk" })
AddEventHandler("onNpcTaskState", function(id, taskId, status, reason)
    if tonumber(taskId) ~= move then return end
    if status == "failure" and reason == "path_blocked" then
        print(("NPC %s cannot reach its destination"):format(id))
    elseif status == "success" and reason == "nearest_reachable" then
        print(("NPC %s stopped next to an unreachable point"):format(id))
    end
end)
```

A native command that still moves the body farther in one frame than its gait allows (the
engine's own answer to "no path" is to place the body on the destination) is cancelled and undone
on the simulating client before anything about it is published. The same guard covers `patrol`,
`wander`, `follow`, `flee`, `guard` and the walk of `enterVehicle`.

### Look, hold and animation

```lua
Open77.npcs.tasks.face(npcId, { x = 1, y = 2, z = 3 }, {
    tolerance = 3, speed = 180, timeoutMs = 5000,
})
Open77.npcs.tasks.lookAt(npcId, { type = "player", id = playerId })
Open77.npcs.tasks.wait(npcId, 1500)
Open77.npcs.tasks.hold(npcId, { durationMs = 5000 })
Open77.npcs.tasks.playAnimation(npcId, "emote_smoke", { loop = false })
```

Named full-body animations remain active until they are cancelled, preempted, timed out or the NPC
streams out. REDengine does not expose a reliable completion signal for every workspot clip, so use
`timeoutMs` or explicit cancellation when the animation must end deterministically.

### Scenarios: workspots

A named animation is one clip. A **workspot** is the engine's authored scenario system -- a body
mounted into a device that plays a whole behaviour with its own props and its own clip set. It is
what the vanilla world uses for a ped smoking on a corner or sitting on a kerb, and it is what
`tasks.workspot` drives.

```lua
for _, profile in ipairs(Open77.npcs.tasks.workspots()) do
    print(profile.id, profile.label, profile.category)
end

Open77.npcs.tasks.workspot(npcId, "smoke", { durationMs = 8000 })
Open77.npcs.tasks.workspot(npcId, "sit", { loop = true })
```

The catalogue is the twelve shipped RP profiles -- `smoke`, `cigar`, `drink`, `phone`, `dance`,
`handsup`, `meditate`, `sit`, `clap`, `cry`, `think`, `stretch` -- and it is the **same list**
`Open77.animations.list()` offers for players. One catalogue, two callers. Each entry names the
vanilla `.workspot` resource it plays, so a profile is traceable back to the authored asset rather
than to a clip name someone typed.

A reference that is not a catalogue id is played as a **raw clip** through the generic device, the
same path `playAnimation` uses. That is the escape hatch and it is not the supported form: a raw
clip's animset may not be loaded by the generic device, in which case the engine simply does not
play it.

| Option | |
|---|---|
| `durationMs` | ends the scenario by **succeeding**. A scenario that ran its course is not a failure, which is why this is not `timeoutMs` |
| `loop` | `true` has no end; the task runs until cancelled or preempted |
| `clip` | a specific clip from the profile's set, instead of its default |

| Reason | Meaning |
|---|---|
| `npc_task_invalid_workspot` | the reference was not a string, or a profile was named together with a clip name |
| `npc_task_invalid_duration` | `durationMs` was negative or above one hour |

### Getting into a vehicle

```lua
Open77.npcs.tasks.enterVehicle(npcId, vehicleId, "driver")
Open77.npcs.tasks.enterVehicle(npcId, vehicleId, 0, { warp = true })
Open77.npcs.tasks.exitVehicle(npcId)
```

Movement channel -- walking to a car is locomotion, so it replaces a standing `moveTo` rather than
composing with it. Seats use the `Open77.vehicles.seats` numbering (`-1` driver, `0` front
passenger, `1` rear left, `2` rear right) and also accept `driver`, `frontPassenger`, `rearLeft`,
`rearRight` by name.

The NPC approaches to within `approachDistance` (default 3 m), then mounts. `warp = true` skips the approach. The task reports `executing` until the native mounting relation confirms the NPC is seated.

`exitVehicle` is idempotent: a body already out of the car has done what was asked and the task
succeeds with reason `not_in_vehicle`. Passing `vehicleId` narrows it to one car, and a body
sitting in a different one then fails with `vehicle_mismatch` instead of being pulled out of it.

| Reason | Meaning |
|---|---|
| `npc_task_invalid_target` | no `vehicleId`, or zero |
| `npc_task_invalid_seat` | outside `-1..2`, or an unknown seat name |
| `vehicle_not_streamed` | no client has that car streamed in, so there is nothing to walk to |
| `vehicle_mount_refused` | the engine refused the seat |

### Driving

An NPC that is the attached driver of a vehicle can be given driving orders **by ped id**:

```lua
Open77.vehicles.ai.attachDriver(vehicleId, { npcId = npcId })

Open77.npcs.tasks.driveTo(npcId, { x = 100, y = 200, z = 10 }, { speed = 20, style = "aggressive" })
Open77.npcs.tasks.driveWander(npcId)
Open77.npcs.tasks.chase(npcId, { playerId = source })
Open77.npcs.tasks.setDriverAbility(npcId, 0.9)
Open77.npcs.tasks.stopDriving(npcId)
```

These are **not** a second driving system. They resolve the vehicle the NPC drives and forward to
[`Open77.vehicles.ai`](server-api.md), which owns the vanilla
`AIVehicleDriveToPointAutonomousCommand` pipeline; two stacks that could disagree about where a car
is going would be worse than one. Every one of them returns `nil, "npc_not_driving"` until the NPC
is actually attached as a driver, and returns the driving state when it is.

Two things do not map from GTA and are named rather than faked:

* **`driveWander` is `joinTraffic`**, the engine's own lane-following mode. Cyberpunk traffic AI
  drives authored lanes; there is no free-roam wander to expose.
* **`setDriverAbility` is a mapping, not a skill parameter.** 2.31 has no driver-ability field, so a
  0..1 ability is mapped onto the two levers that do exist: below `0.34` is `cautious`, above `0.67`
  is `aggressive`, otherwise `normal`; the speed cap becomes `8 + ability * 24` m/s. It returns the
  resulting driving state so a caller reads what was applied instead of assuming.

`attachDriver` refuses an NPC that holds any live task (`driver_npc_has_tasks`), so finish or cancel
an `enterVehicle` before attaching.

### Generic queue and cancellation

```lua
local taskId = Open77.npcs.tasks.enqueue(npcId, "moveTo", {
    x = 1, y = 2, z = 3, speed = "walk",
}, { channel = Open77.npcs.channels.movement, priority = 10, timeoutMs = 30000 })

local task = Open77.npcs.tasks.get(npcId, taskId)
local tasks = Open77.npcs.tasks.all(npcId)
Open77.npcs.tasks.cancel(npcId, taskId, "script_cancel")
Open77.npcs.tasks.clear(npcId, Open77.npcs.channels.movement, "new_route")
```

Only the task names listed above are accepted. Unsupported types and malformed targets, paths,
durations or animation names are rejected before replication.

The five driving verbs are the exception to the shape above: they are commands, not queued tasks,
because the job they drive lives in the vehicle AI service and already has its own lifecycle. They
return a driving state rather than a task id.

### Task API reference

| Function | Parameters | Return |
|---|---|---|
| `Open77.npcs.tasks.enqueue` | `npcId, type, parameters, optional options` | task ID |
| `Open77.npcs.tasks.moveTo` | `npcId, position, optional options` | task ID |
| `Open77.npcs.tasks.follow` | `npcId, target, optional options` | task ID |
| `Open77.npcs.tasks.patrol` | `npcId, points, optional options` | task ID |
| `Open77.npcs.tasks.wander` | `npcId, optional options` | task ID |
| `Open77.npcs.tasks.face` | `npcId, target, optional options` | task ID |
| `Open77.npcs.tasks.lookAt` | `npcId, target, optional options` | task ID |
| `Open77.npcs.tasks.wait` | `npcId, durationMs, optional options` | task ID |
| `Open77.npcs.tasks.hold` | `npcId, optional options` | task ID |
| `Open77.npcs.tasks.playAnimation` | `npcId, animation, optional options` | task ID |
| `Open77.npcs.tasks.workspot` | `npcId, profile or clip, optional options` | task ID |
| `Open77.npcs.tasks.workspots` | `optional query` | array of catalogue profiles |
| `Open77.npcs.tasks.enterVehicle` | `npcId, vehicleId, optional seat, optional options` | task ID |
| `Open77.npcs.tasks.exitVehicle` | `npcId, optional options` | task ID |
| `Open77.npcs.tasks.driving` | `npcId` | driving state or `nil, reason` |
| `Open77.npcs.tasks.driveTo` | `npcId, position, optional options` | driving state or `nil, reason` |
| `Open77.npcs.tasks.driveWander` | `npcId, optional options` | driving state or `nil, reason` |
| `Open77.npcs.tasks.chase` | `npcId, target, optional options` | driving state or `nil, reason` |
| `Open77.npcs.tasks.stopDriving` | `npcId` | driving state or `nil, reason` |
| `Open77.npcs.tasks.setDriverAbility` | `npcId, ability` | driving state or `nil, reason` |
| `Open77.npcs.tasks.attack` | `npcId, target, optional options` | task ID |
| `Open77.npcs.tasks.guard` | `npcId, position, optional radius, optional options` | task ID |
| `Open77.npcs.tasks.flee` | `npcId, from, optional options` | task ID |
| `Open77.npcs.tasks.get` | `npcId, taskId` | task snapshot or `nil` |
| `Open77.npcs.tasks.all` | `npcId` | array of task snapshots |
| `Open77.npcs.tasks.cancel` | `npcId, taskId, optional reason` | boolean |
| `Open77.npcs.tasks.clear` | `npcId, optional channel, optional reason` | number cancelled |

Common options are `priority` (signed integer) and `timeoutMs` (`0` disables the timeout). Generic
`enqueue` also accepts `channel`. A task snapshot contains `npcId`, `id`, `resource`, `type`, the
JSON string `parameters`, `channel`, `priority`, `timeoutMs`, `status`, `revision` and `reason`.
Statuses are `queued`, `suspended`, `executing`, `success`, `failure`, `cancelled` and
`interrupted`.

## Attitude and relationship groups

`setAttitude` decides who an NPC will fight. It is **per NPC and per target**, and that is not a
design preference -- it is the only shape the engine can express for one incarnation. A
`gameAttitudeAgent` carries `SetAttitudeTowards(otherAgent, attitude)`, an override on one pair of
agents, which is what Open77 writes. The engine's other lever, a global group matrix reached through
`gameCAttitudeManager.SetAttitudeRelation`, is process-wide and shared with vanilla content, so
Open77 does not touch it; `docs/research/npc-behavior-control.md` records that measurement in full.

```lua
-- Hostile to every player and to every other NPC of this resource.
Open77.npcs.setAttitude(npcId, "hostile")

-- ...except this one player, who is an ally.
Open77.npcs.setAttitude(npcId, "friendly", { towards = { playerId = playerId } })

-- ...and this NPC, which it simply ignores.
Open77.npcs.setAttitude(npcId, "neutral", { towards = { npcId = otherNpcId } })

-- Take the exception back; the default applies again.
Open77.npcs.setAttitude(npcId, nil, { towards = { playerId = playerId } })
```

Rows resolve in a fixed order, most specific first:

1. the per-entity row (`{ playerId = n }` or `{ npcId = n }`);
2. the per-group row (`{ group = "name" }`);
3. the relationship row declared by `setRelationship` for the two NPCs' groups;
4. the default set by `setAttitude` with no `towards`;
5. otherwise the NPC behaves exactly as it did before any of this existed.

Two rules are worth reading twice. An NPC that states **nothing** replicates the same loadout bytes
it always did and is resolved by the pre-existing rules, so adding this API changed no running
resource. And player **eligibility is a hard cap, not a default**: a dead player, or one in another
routing bucket, is never made a target by a directive. A directive can only ever make an NPC
friendlier toward an ineligible player, never hostile.

Attitude survives a wholesale `setLoadout`, the way `behavior` does, because it is policy rather
than equipment. `combat.group` does not: it is an ordinary loadout field with pre-existing meaning.

### Relationship groups

Groups are the readable way to run more than two sides. `setGroup` writes the same `combat.group`
field a loadout has always been able to carry -- two NPCs sharing a non-empty group are allies --
and `setRelationship` declares what happens between two groups.

```lua
Open77.npcs.setGroup(copId, "police")
Open77.npcs.setGroup(gangerId, "valentinos")
Open77.npcs.setRelationship("police", "valentinos", "hostile")
Open77.npcs.setRelationship("police", "medics", "friendly")
```

The matrix is **per resource**, symmetric, and dies with the resource that declared it: one
resource's groups never reach another's bodies, exactly as its NPC reads and writes never do. The
resulting row is projected onto every owned NPC in either group and replicated with it, so every
observer resolves the pair without asking the server -- and an NPC that joins a group later picks up
the standing row. When two NPCs state attitudes about each other that disagree, **the more hostile
side wins**: a one-sided friendship produces a body that is shot at and does not shoot back.

### Combat tasks

The combat tasks are ordinary queue entries on the existing channels, so they compose with the
movement and look tasks already there.

```lua
-- Engage. Action channel: a standing `moveTo` or `patrol` is NOT cancelled -- the engine
-- suspends it for the fight and resumes it afterwards.
Open77.npcs.tasks.attack(npcId, { playerId = playerId })
Open77.npcs.tasks.attack(npcId, { npcId = rivalId }, { reacquireMs = 3000 })

-- Hold a doorway. Standing order: it ends on cancel or timeout, never on its own.
Open77.npcs.tasks.guard(npcId, { x = 10, y = 20, z = 30 }, 12, { speed = "run" })

-- Break contact.
Open77.npcs.tasks.flee(npcId, { playerId = playerId }, { distance = 60 })
```

`attack` forces the pair hostile and seeds the target into the puppet's own target tracker, then
re-seeds on `reacquireMs`. It issues **no** locomotion or shooting command: the engine's combat AI
owns the body from there, which is why an attacking NPC can still take cover and manoeuvre. It
reports `success` when the target dies, streams out or is removed, and `failure` with
`attack_target_unavailable` when the target could not be resolved at all. Cancelling drops the
seeded threat but leaves the attitude alone -- that was a deliberate statement, and a task must not
overwrite it.

`guard` is a leash, not a patrol. Whenever the body is further than `radius` from the anchor **and
is not already in a fight** it walks back; while it holds a hostile threat it is left entirely
alone. It does not by itself make anything hostile: pair it with `setAttitude` or a group.

`flee` re-measures a live target every cycle, so an NPC keeps breaking contact as the thing it flees
moves, and reports `success` once `distance` metres separate them. It drops the tracked threat on
that body once, which is best-effort and deliberately shallow: the attitude is untouched, so a
still-hostile NPC may re-acquire the same target by perception.
`setAttitude(npcId, "neutral", { towards = ... })` before the flee is the composable answer.

**There is no `cover` task.** Native cover commands require authored `NodeRef` positions that Open77 cannot create or enumerate. NPCs can use native cover during combat. The `guard` task uses a `moveTo` leash rather than a native guard-area command, which has the same `NodeRef` restriction.

## Health, damage and death

```lua
Open77.npcs.applyDamage(npcId, 25, "resource:arena", "firearm")
Open77.npcs.kill(npcId, "admin")
Open77.npcs.revive(npcId, 100)
```

- `mortal` allows health to reach zero.
- `immortal` applies damage but clamps health to 1.
- `invulnerable` rejects damage.

Health and death are canonical server state. Client hit detection should request a bounded server
action; it must not directly mutate a projection.

## Events

Server resource events:

```lua
AddEventHandler("onNpcCreated", function(npcId, resource, template) end)
AddEventHandler("onNpcUpdated", function(npcId, revision) end)
AddEventHandler("onNpcRemoved", function(npcId, reason, resource) end)
AddEventHandler("onNpcTaskState", function(npcId, taskId, status, reason) end)
AddEventHandler("onNpcAuthorityChanged", function(npcId, playerId, epoch, reason) end)
AddEventHandler("onNpcDamaged", function(npcId, source, amount, health, cause) end)
AddEventHandler("onNpcDied", function(npcId, source, cause) end)
AddEventHandler("onNpcTargetChanged", function(npcId, kind, targetId, previousKind, previousTargetId) end)
AddEventHandler("onNpcInteracted", function(npcId, playerId, interactionId, choiceId, distance) end)
```

The same creation and removal also reach `onEntityCreated(kind, id, resource)` and
`onEntityRemoved(kind, id, reason)` with `kind` = `"npc"`, for a resource that declares
`world.entities.observe` and wants every registry under one name. The mirror is raised by the
same statement as the event above it, so the two can never disagree — see
[entity lifecycle events](server-api.md#entity-lifecycle-events).

`onNpcTargetChanged` is delivered **only to the resource that owns the NPC**, unlike the six above
it, because NPC reads are owner scoped everywhere else in this API and an event telling every
resource which player another resource's bodyguard had just drawn on would be a read across that
boundary. `kind` is `none`, `player` or `npc`, and `targetId` is `0` when `kind` is `none`. All five
arguments are strings, as every server resource event's arguments are.

`onNpcInteracted` is a **player's** act on an NPC — a choice used on a `globalNpc`
[interaction target](interactions.md#event-payload-and-server-authority) — and is delivered
host-wide like `onNpcDamaged`: the owner is not the only resource entitled to know that a player
pressed a key on its clerk. The bundled prompt reports the use to the server; the server refuses
a report from a player it cannot place, from another routing bucket, or from more than 40 m away,
and publishes the event only for an accepted one, with `distance` being **its own** measurement
between the player's last fresh snapshot and the NPC's canonical position (metres, two decimals).
`interactionId` is the target's materialised id (`<targetId>:<matchKey>`) and `choiceId` the
choice; a refused report produces no event at all, so a forged one can only be silent. Apply your
own rule on `distance` — 40 m is the ceiling that keeps a sprinting player's stale snapshot from
breaking a real prompt, not the reach of your shop.

```lua
-- A vendor: one NPC per stall, opened by the prompt the interactions resource shows on it.
local stalls = {}   -- npcId (string) -> catalogue

CreateThread(function()
    local npcId = Open77.npcs.create({ record = "Character.Judy", position = { x = -1378.0, y = 1262.0, z = 123.0 } })
    stalls[tostring(npcId)] = "weapons"
    exports.open77_interactions:define({
        { id = "stall", kind = "globalNpc", distance = 2.0, label = "Browse", key = "E", event = "market:browse" },
    })
end)

AddEventHandler("onNpcInteracted", function(npcId, playerId, interactionId, choiceId, distance)
    local catalogue = stalls[npcId]
    if catalogue == nil or tonumber(distance) > 3.0 then return end
    TriggerClientEvent("market:open", tonumber(playerId), catalogue)
end)
```

Client resource events:

```lua
AddEventHandler("onNpcStreamIn", function(npcId, revision) end)
AddEventHandler("onNpcReady", function(npcId, entity) end)
AddEventHandler("onNpcSpawnFailed", function(npcId, record, reason) end)
AddEventHandler("onNpcChanged", function(npcId, revision) end)
AddEventHandler("onNpcTaskChanged", function(npcId, taskId) end)
AddEventHandler("onNpcAuthorityChanged", function(npcId, playerId) end)
AddEventHandler("onNpcStreamOut", function(npcId, reason) end)
```

**No `RequestModel` / `HasModelLoaded` loop is needed.** Streaming is implicit; `create` returns
before the body exists anywhere, `isStreamedIn(id)` says whether this client has it and `onNpcReady`
is the transition. `Open77.npcs.whenReady` folds both into one promise, on both runtimes:

```lua
-- Client, requires npcs.read. Resolves with { id, entity } the instant isStreamedIn(id)
-- answers true (the same instant onNpcReady fires: attached AND behaving), before the call
-- returns when it already is; rejects with `timeout` (default 15000 ms, clamped 1..120000).
Open77.npcs.whenReady(npcId, 8000):next(function(body)
    Open77.animations.play(body.entity, "wave")
end, function(reason) print("vendor never appeared: " .. reason) end)

-- Server, requires world.npcs. Resolves with the Open77.npcs.owner(id) table as soon as at
-- least one client reports a ready projection (readyClients > 0) -- the election's own
-- `onNpcAuthorityChanged(..., "projectionready")` event, with a 250 ms poll as the safety
-- net -- and rejects with `timeout`.
local guard = assert(Open77.npcs.create({ template = "civilian_female_relaxed_01", position = post }))
Open77.npcs.whenReady(guard, 10000):next(function(owner)
    Open77.npcs.tasks.patrol(guard, route, { loop = true })
end, function(reason) print(("guard %d never streamed: %s"):format(guard, reason)) end)
```

The one-shot handler is removed on resolve, on timeout and with the resource. `nil, reason` only
for a bad id, a bad timeout or a missing permission; an unknown id is waited for and times out.

## Client read-only API

`onNpcSpawnFailed(npcId, record, reason)` is a **local diagnostic**, not a server-authoritative
failure. Reasons are `npc_record_invalid`, `npc_record_not_found`, `npc_record_not_character`,
`npc_spawn_failed` and `npc_spawn_timeout` (30 seconds). A failed projection is not retried every
frame: it remains failed until a new incarnation (stream-out/in, recreation, record/appearance
change). Transient spawn-service saturation is retried at most once every two seconds. Never
trust a client failure notification to delete canonical server state automatically.

```lua
local npc = Open77.npcs.get(npcId)
local visible = Open77.npcs.all()
local ready = Open77.npcs.isStreamedIn(npcId)
local entity = Open77.npcs.entity(npcId)       -- local Open77 entity handle or nil
local taskId = Open77.npcs.currentTask(npcId) -- canonical current task ID or nil
```

Snapshots expose `streamed` and `locallyAuthoritative`. The local entity handle is ephemeral: do
not cache it across stream-out, reconnect or resource reload.

Client snapshots contain `id`, `revision`, `entity`, `template`, `record`, `appearance`, `flags`, `bucket`,
`aiMode`, `damagePolicy`, `health`, `maxHealth`, `currentTaskId`, `taskRevision`,
`authorityPlayerId`, `authorityEpoch`, `streamed` and `locallyAuthoritative`. Without `npcs.read`,
`all()` returns an empty array, `get()`/`entity()`/`currentTask()` return `nil`, and
`isStreamedIn()` returns `false`.

## Current limitations

- Server persistence storage is resource-defined; `persistent=true` only changes cleanup policy.
- Records outside `Character.*`, raw REDengine handles and client-side canonical mutation are
  unsupported. A valid Character record may still depend on unavailable DLC/assets or quest logic.
- Native attack/shoot/melee/combat tasks are not in the stable API yet. Use server-authoritative
  scripted damage until animation, targeting and hit validation are proven safe for each rig.
- Native authored patrol paths (`NodeRef`) are not exposed; Open77 patrols sequence `moveTo` tasks.
- A client authority lease is required for native navigation. With no ready client, tasks suspend
  and resume when authority becomes available.
