# NPCs: thinking characters as data

An NPC type is **one TypeScript file of data**. You describe what the character knows (facts), what it
can be tuned by (props), and a short ordered list of rules. The engine compiles that once and runs it for
thousands of characters at a time. No code of yours runs per character per tick. There are no behaviour
trees and no blackboards.

## What an NPC type is

- One file in `behaviors/`, for example `behaviors/guard.ts`. One NPC type per file.
- It ends with `export default npc({ ... })`.
- A scene attaches it with a `script` component. The component's `props` override the type's props for
  that one character:

```json
{"type": "script", "module": "behaviors/guard", "props": {"walk": 1.0, "route": [[0,0,0],[10,0,0],[10,0,10]]}}
```

- A number prop takes a number. A route prop takes a list of `[x, y, z]` points. An unknown prop name or a
  wrong type is an error that names the prop.
- Every NPC with the same module is the same type and shares the same rules. They differ only by props and
  by their own fact values.
- There are no per-character hooks (`init`, `update`). The file only describes data.

```ts
import { npc, t } from "@argus/gameplay"
```

## How a character decides

Every time it thinks, an NPC reads what it senses, its facts and its props, then walks its rules **in
order**. The **first rule whose `when` is true wins**. Its action becomes the NPC's current action, and its
`sets` change facts. If no rule matches, `otherwise` runs. Put the most urgent rules first.

A rule can `hold(ticks)`: once it wins, its decision stays for at least that many ticks, even if a
higher rule would now win. Use it to stop flicker (a villager that keeps switching between fleeing and
standing still).

Count time with `hold` and `me.ticksSince`, not with fact counters that you add to every tick.

## Time, moments and memory

The world runs **60 ticks a second**: `hold(60)` and `wait(60)` are one second, `hold(300)` five.

**Senses are moments.** `sensed.seen` is true only on the ticks a target is in view. An NPC that turns
its back to run away stops seeing what it runs from at once. `sensed.heard` is true only on the one tick
after a noise is made, because a noise does not last. So anything that should carry on after the moment
needs a fact. Running until far enough away, going to where an alarm came from, searching where a target
was last seen: remember the point when you sense it, then act on the fact until you are done.

<!-- ease:moments -->
```ts
facts: { alarm: t.point(), alarmed: t.bool(false) },
rules: ({ fact, sensed, me, act, rule }) => [
  // the moment: remember where the noise came from (a point costs 3 writes, the flag 1)
  rule("hear").when(sensed.heard).do(act.moveTo(sensed.noisePos, 4), { alarm: sensed.noisePos, alarmed: true }),
  // afterwards: keep going until there
  rule("go to alarm").when(fact.alarmed.and(me.pos.distXZ(fact.alarm).gt(1.5))).do(act.moveTo(fact.alarm, 4)),
  rule("arrived").when(fact.alarmed).do(act.idle(), { alarmed: false }),
],
```
<!-- /ease:moments -->

The same shape works for fear: remember where the threat was (`sensed.targetPos`) and flee from the fact
until `me.pos.distXZ(fact.threat)` is large enough, then let the next rule take the NPC home.

More precise rules:

- An NPC never hears its own noise. Every other NPC within `loudness * hearing.scale` metres hears it,
  whatever it is doing; whether it reacts is up to its rules (a rule above `hear` wins first).
- When a rule stops winning, the next winner's action takes over on that tick; nothing finishes first.
- `me.ticksSince` counts from the tick the current rule took over. It does not reset while the same rule
  keeps winning. On a rule's first tick it still belongs to the rule before it, so a rule that times itself
  ("stay here for 600 ticks") also tests `me.doing(...)`: `me.doing("idle").not().or(me.ticksSince.lt(600))`.
- A rule's `sets` apply on the tick it wins, including for `wait`.
- **Go somewhere, then stay a while: give it two stages.** A stay timed with `me.ticksSince` counts from the tick its
  rule took over, so if one rule both walks there and stays, the walk eats the stay (a 35 m walk at 1.5 m/s is about
  1,400 ticks). Use an enum fact with a walking stage and a staying stage per place, and start the stay on arrival:

  ```ts
  rule("to the pier").when(fact.stage.is("toPier").and(me.pos.distXZ(prop.pier.at(0)).gt(1.5))).do(act.moveTo(prop.pier.at(0), 2)),
  rule("at the pier").when(fact.stage.is("toPier")).do(act.idle(), { stage: "loading" }),              // arrived
  rule("loading").when(fact.stage.is("loading").and(me.doing("wait").not().or(me.ticksSince.lt(prop.loadTicks)))).do(act.wait(1)),
  rule("loaded").when(fact.stage.is("loading")).do(act.idle(), { stage: "toPier2" }),
  ```

  For a day keyed to a clock rather than to tick counts, use a `schedule` (see "Factions, shared facts, groups, schedules and events").
- `sensed.blocked` looks along the direction the NPC is trying to move (where its current action is
  taking it), about one step ahead.
- To keep walking in a direction, `moveTo` a point ahead of yourself, such as
  `me.pos.offset(fact.dir.mul(10), 0, 0)`. The point moves with you, so it is never reached.
- `otherwise` is a function that gets the same names as `rules`, so it can use facts and props:
  `otherwise: ({ fact, me, prop, act }) => act.moveTo(...)`.

## The builder

```ts
npc({
  name: string,                       // the type's name (shown in the decision log and in errors)
  facts?: { [name]: FactType },       // per-character state, with defaults
  props?: { [name]: PropType },       // tunables; the scene can override them per character
  senses?: Senses,                    // what it perceives
  movement?: Movement,                // speed limits
  rules: (api) => RuleB[],            // priority order, first match wins (per action slot)
  otherwise: Action | ((api) => Action | OtherwiseB),   // when no rule matches
})
```

`npc()` returns an object `{ __npcIr, init() {} }`. Do not touch it. The engine reads `__npcIr`, which
is plain JSON (`"npcIr": 1`). The builder throws a plain `Error` for mistakes that the types cannot catch.

### Facts

A fact is a typed field that belongs to one character and that rules can change. Declare them with `t`:

| Declaration | Holds | Read in a rule as |
|---|---|---|
| `t.num(0)` | a number | number |
| `t.int(0)` | a whole number, 0 or more (a counter or an index) | number |
| `t.bool(false)` | true or false | boolean |
| `t.enum(["calm", "fleeing"], "calm")` | one of the named values (default: the first) | enum: `fact.mode.is("calm")`, `fact.mode.isNot("calm")` |
| `t.entity()` | another entity, or nothing | entity: `fact.buddy.isNone()`, `isSome()`, `is(other)` |
| `t.point([0, 0, 0])` | a position | point (see below) |

### Props

A prop is a read-only tunable with a default. Rules read it. Only the scene changes it.
Sense and movement settings (`senses`, `movement`) are numbers in the type, not props, so they are the same
for every character of the type.

| Declaration | Read in a rule as |
|---|---|
| `t.num(1.5)` | `prop.speed` (a number) |
| `t.route([[0,0,0],[5,0,0]])` | `prop.path.at(i)` (a point, the index wraps round) and `prop.path.len` (a number) |

### Senses

What the NPC perceives. Numbers only (a sense value cannot be a prop).

```ts
senses: {
  sight:   { range: 15, fov: 120, eyeHeight: 1.6 },   // metres, degrees (full cone), metres
  hearing: { scale: 1 },                              // heard when distance <= loudness * scale
  wallAhead: 0.6,                                     // metres to probe for a wall; sets sensed.blocked
  awareness: { sightRate: 0.1, hearBump: 0.3, decay: 0.01, suspiciousAt: 0.3, alertedAt: 0.8 },
  targets: "players",                                 // or "players+others"
  maxRays: 1,                                         // line-of-sight checks per tick
}
```

- **Leave `senses` out and you get sight and hearing with the defaults above.**
- **Write `senses` and only the senses you name are on.** `senses: { wallAhead: 0.6 }` has no sight and no
  hearing. Use `hearing: {}` to turn hearing on with its defaults.
- Every number inside is optional. The values shown are the defaults.
- `targets: "players+others"` also lets the type see the NPCs of every OTHER NPC type (never its own type).
  Positions are the ones from before this tick, so the result does not depend on the order types run in. The
  default, `"players"`, ignores NPCs entirely.
- Deeper perception (memory, a list of seen entities, noise kinds, angle weighting, hysteresis) is opt-in:
  see [Perception depth](#perception-depth). A type that names none of it behaves exactly as before.
- Checks: `range` > 0, `0 < fov <= 360`, `scale`, `wallAhead`, `eyeHeight` >= 0, `suspiciousAt <= alertedAt`
  (both 0 to 1), `maxRays` a whole number.

### Perception depth

Four opt-in extras on top of the basic senses. Each is off until the type's `senses` names it, costs nothing when
off, and leaves the NPC's behaviour and replay hashes exactly as they were. A rule that reads `memory.*` or the seen
list without the matching sense is a compile error that names the setting to add.

#### Awareness: angle and hysteresis

Awareness is a meter from 0 to 1. It builds while the NPC sees a target, faster the nearer the target is, and now
(optionally) the more central it is in the cone. A noise bumps it, and it decays when nothing is sensed. Two states
sit on top: **suspicious** (at `suspiciousAt`) and **alerted** (at `alertedAt`).

```ts
senses: {
  sight: { range: 20, fov: 110 },
  awareness: {
    angleBias: 0.6,        // 0 (default): the angle does not matter; 1: nothing is gained at the very edge of the cone
    suspiciousExit: 0.15,  // once suspicious, stay so until awareness falls below this (default = suspiciousAt)
    alertedExit: 0.5,      // once alerted, stay so until it falls below this (default = alertedAt)
  },
}
// rules: sensed.alerted, sensed.suspicious, sensed.state.is("alerted"), sensed.awareness
rule("search").when(sensed.state.is("alerted").and(sensed.seen.not())).do(...)
```

Entering a state always takes the `*At` value; the `*Exit` values only keep it. That stops a guard flickering between
alert and calm while the player hides at the edge of its sight. Checks: each exit is at most its `*At`, and
`suspiciousExit <= alertedExit`.

#### Memory: where it last saw someone

`sensed.seen` and `sensed.heard` are moments. With `memory` the engine also keeps the last place the target was seen
and the last noise for `forget` ticks (60 a second), with no facts to write:

```ts
senses: { sight: {}, hearing: {}, memory: { forget: 600 } },   // ten seconds
rules: ({ me, memory, prop, act, rule }) => [
  rule("search where last seen")
    .when(memory.has.and(me.pos.distXZ(memory.lastSeenPos).gt(1.5)))
    .do(act.moveTo(memory.lastSeenPos, prop.walk)),
  rule("investigate the noise").when(memory.lastNoiseAgo.lt(300)).do(act.moveTo(memory.lastNoisePos, 2)),
],
```

- `memory.has`: a sighting is remembered. `memory.lastSeenPos` (point) and `memory.lastSeenAgo` (ticks; 0 while the
  target is in view, 1e9 when nothing is remembered).
- `memory.lastNoisePos`, `memory.lastNoiseAgo`, `memory.lastNoiseKind` are the same for the last noise heard.
- The sighting and the noise each expire `forget` ticks after they happened (`forget` is a whole number above 0).
- The remembered position is the target's last seen position, so a target that moved on is not tracked.

#### Noise kinds

A noise is one of `"noise"`, `"footstep"`, `"gunshot"`, `"alarm"`, `"damage"`. An NPC makes one with
`act.emitNoise(30, "alarm")`; the engine can also raise one from outside (`NpcSim::raise_stimulus`). Hearing scales the loudness
per kind (`1` unless named, `0` is deaf to that kind), and a rule can tell what it heard:

```ts
senses: { hearing: { scale: 1, kinds: { footstep: 0.4, gunshot: 2, damage: 0 } } },
rule("check the shot").when(sensed.heard.and(sensed.heardKind.is("gunshot"))).do(act.moveTo(sensed.noisePos, 4)),
```

With these numbers a footstep of loudness 10 is heard within 4 m, a gunshot of loudness 10 within 20 m, and damage
never. `heardKind` is the kind of the loudest noise heard this tick (loudness after scaling).

#### A list of seen entities

By default an NPC sees one target, the nearest. `seenMax: N` (1 to 8) tracks up to N at once, so a guard with
several players in view can pick one:

```ts
senses: { sight: { range: 22 }, seenMax: 4, maxRays: 4 },
rule("chase the most aware").when(sensed.seenCount.gt(0)).do(act.moveTo(sensed.mostAware, 4)),
rule("shout for backup").when(sensed.seenCount.gt(1)).do(act.emitNoise(30, "alarm")),
```

- `sensed.seenCount`: how many are in view. `sensed.nearest` / `nearestDist`: the nearest one in view.
  `sensed.mostAware` / `mostAwareLevel`: the tracked entity with the highest awareness, which can be one that just left view.
- `sensed.seenAt(i)` gives entry `i` as `.entity`, `.awareness` and `.dist`. Entry 0 is the most salient (close and central
  count most; equal salience goes to the lower entity id), a total order, so it does not depend on the thread count.
- Each entry has its own awareness: it builds while that entity is in view and decays at the type's `decay` once it is
  not, and the entry is dropped when it reaches 0. `sensed.awareness` stays the overall meter.
- `sensed.target` is entry 0 when a list is on, and `memory.lastSeenPos` follows it.
- Line of sight is tested with `maxRays` rays a tick. With fewer rays than candidates the rays rotate over them, so each
  is checked every `candidates / maxRays` ticks and one seen last tick is kept until then. Set `maxRays` to `seenMax`
  for an exact list every tick.
- Entities past the 16 nearest candidates are not considered.

### Movement

```ts
movement: { speed: 3.5, accel: 8, turnRate: 360, radius: 0.35 }   // m/s, m/s^2, degrees/s, metres
```

All optional, plus `controller` (see Controllers). `speed` is the most the NPC can ever move, whatever speed an action asks for: an action asking for more is quietly capped, so set `speed` at least as high as your fastest action.

**Movement animations.** Name the animator states the NPC should be in while it moves:

```ts
movement: { speed: 4, anims: { idle: "stand", walk: "walk", run: "run", runAbove: 2.5 } }
```

Every action except `playAnim` and `custom` picks one by the speed it asks for: `run` from `runAbove` m/s
(default 0.6 of `speed`), `walk` while moving slower, `idle` when still. A state you leave out falls back to
the next slower one (no `idle` and standing still: nothing is set). The entity needs an `animator` component
with states of those names; the engine switches state when the pick changes, and a script that set the
animator itself that tick wins. Leave `anims` out and movement sets no animation (as before).

**Animator state names** used by `playAnim("name")` and `movement.anims` need no declaration. Only numeric
`playAnim(n)` needs `anims: ["salute", "wave"]` on the type (`anims` sits next to `movement`).

### Rules

```ts
rules: ({ fact, prop, sensed, me, act, rule }) => [
  rule("flee")                      // a unique name; it appears in the decision log
    .when(sensed.seen)              // a boolean expression
    .do(act.fleeFrom(...), { mode: "fleeing" })   // an action, and optional fact writes
    .hold(30),                      // optional: keep this decision at least 30 ticks
]
```

- `.when(true)` is a catch-all: put it last for "otherwise in this slot".
- `sets` (the second argument of `do`) writes facts **after** the rule is chosen. Writes read the facts as
  they were before this tick.
- A set value is an expression of the fact's type. An enum fact takes a plain string: `{ mode: "calm" }`.
  A point fact takes a point expression or an `[x, y, z]` list.
- At most **4 fact columns** per rule. A point counts as 3.
- After `.do(...)` a rule can chain `.hold(ticks)`, `.slot("aim" | "upper" | "voice")`, `.priority(n)` and `.cooldown(ticks)`
  (see Action slots).
- `otherwise: ({ act }) => act.idle()`. To write facts there too:
  `otherwise: ({ act, otherwise }) => otherwise(act.idle(), { mode: "calm" })`.

### Expressions

TypeScript has no operator overloading, so expressions are values with methods. Plain numbers and
booleans are accepted wherever an expression is.

| On | Methods |
|---|---|
| number | `add sub mul div min max` (number); `neg() abs() clamp(lo, hi)` (number); `lt le gt ge eq ne` (boolean) |
| boolean | `and(b) or(b) not() eq(b)` |
| point | `.x .y .z` (numbers); `distTo(p)` (3D distance); `distXZ(p)` (ignores height); `offset(dx, dy?, dz?)` (a new point) |
| entity | `isNone() isSome() is(other)` |
| enum | `is("value") isNot("value")` |

Division by zero gives 0. Not-a-number never gets stored (it becomes 0).

Free functions in the callback: `point(x, y, z)`, `none` (no entity), `random()` (0 up to 1, different for
each character and each tick, but always the same on a replay), `min(a, b)`, `max(a, b)`, `abs(a)`,
`clamp(a, lo, hi)`, `dist(p, q)`, `distXZ(p, q)`, `not(b)`, `and(...)`, `or(...)`,
`select(cond, a, b)` (a if cond, else b; both are always computed).

## What a rule can read

| Read | Type | Meaning |
|---|---|---|
| `fact.<name>` | by declaration | this character's fact |
| `prop.<name>` | number or route | this character's prop |
| `sensed.seen` | boolean | a target is in view this tick |
| `sensed.target` | entity | the most noticeable target in view (none if unseen) |
| `sensed.targetPos` | point | where it is |
| `sensed.targetDist` | number | distance to it, 1e9 if unseen. It is a 3D distance to the target's chest, 1 m above its position, so use `me.pos.distXZ(sensed.targetPos)` to judge touching |
| `sensed.awareness` | number | 0 (unaware) to 1 (alerted) |
| `sensed.suspicious` | boolean | awareness reached `suspiciousAt` (true when alerted too; see hysteresis) |
| `sensed.alerted` | boolean | awareness reached `alertedAt` |
| `sensed.state` | enum | `sensed.state.is("unaware")`, `"suspicious"` or `"alerted"` |
| `sensed.heard` | boolean | a noise was heard this tick (noises last one tick; remember them in a fact) |
| `sensed.heardCount` | number | how many |
| `sensed.heardKind` | enum | what the loudest noise was: `.is("noise")`, `"footstep"`, `"gunshot"`, `"alarm"`, `"damage"` |
| `sensed.noisePos` | point | where the loudest came from |
| `sensed.noiseDist` | number | distance to it, 1e9 if none |
| `sensed.blocked` | boolean | a wall **or a ledge** is just ahead in the direction it is trying to move (needs `senses.wallAhead`). A drop counts: a cell with no walkable ground is blocked, so a character walking a platform turns at its edges with no extra sense |
| `sensed.seenCount`, `sensed.nearest`, `sensed.nearestDist`, `sensed.mostAware`, `sensed.mostAwareLevel`, `sensed.seenAt(i)` | | the list of seen entities (needs `senses.seenMax`) |
| `memory.has`, `memory.lastSeenPos`, `memory.lastSeenAgo`, `memory.lastNoisePos`, `memory.lastNoiseAgo`, `memory.lastNoiseKind` | | what it still remembers (needs `senses.memory`) |
| `me.pos` | point | its position |
| `me.home` | point | where it was placed (its spawn position; never changes) |
| `me.yaw` | number | its heading, radians (0 faces +Z) |
| `me.id` | number | its entity number |
| `me.ticksSince` | number | ticks since the current rule took over (60 a second) |
| `me.doing("moveTo")` | boolean | is its current action this one? (`idle moveTo face wait fleeFrom attack findPoint playAnim patrol emitNoise custom drive`) |
| `me.doingIn("aim", "face")` | boolean | the same for another slot (`aim`, `upper`, `voice`; see Action slots) |
| `random()` | number | see above |

`sensed.targetPos` and `sensed.noisePos` are only meaningful when `seen` or `heard` is true. Guard the
rule with that.

## Actions

Pass one to `.do(...)`. Speeds are in metres per second. Movement happens on the ground plane.
"Target" means a point expression or an entity expression (such as `sensed.target`, or a player).

| Action | What it does |
|---|---|
| `act.idle()` | Stand still. |
| `act.moveTo(target, speed)` | Walk to the target at `speed`. Stops on arrival. An entity target is followed as it moves (round obstacles, by a flow field); a point is walked to round obstacles by a flow field too, and in a straight line when nothing blocks it. A point farther than the field's reach (1.5x sight, at least 20 m) is walked straight until the NPC is within reach; when many NPCs want different points, a few fields are built per tick and the rest walk straight meanwhile. |
| `act.face(target)` | Turn to look at the target. Stand still. |
| `act.wait(ticks)` | Stand still. A constant number of ticks also holds the rule that long. |
| `act.fleeFrom(target, distance, speed)` | Run directly away until `distance` metres from the target. Then stand. Flee from a remembered point fact if it must go on after the target is out of sight. |
| `act.attack(entity, range, cooldownTicks)` | Face the entity. When it is within `range` and the cooldown boundary comes round (every `cooldownTicks` ticks while this rule keeps winning), the engine emits an `npc.attack` event. It does not walk: win a `moveTo` rule when the target is far and this rule when it is near. |
| `act.findPoint.randomAround(center, radius, into)` | Pick a random point within `radius` of `center` and write it to the point fact `into` (pass `fact.<name>`). Costs 3 of the 4 writes. The NPC stands still this tick. |
| `act.findPoint.nextWaypoint(route, index, into)` | Write waypoint number `index` of the route prop to the point fact `into`, and advance the int fact `index` (wrapping). Costs all 4 writes. |
| `act.playAnim(state)` | Stand still and put the entity's animator in state `state` (a name, e.g. `act.playAnim("salute")`). A number also works: it picks the n-th name of the `anims` list (1 is the first; numbers outside the list do nothing). The entity needs an `animator` component with a state of that name. |
| `act.patrol(route, index, speed)` | Walk the route prop in a loop. The int fact `index` remembers which waypoint is next. An empty route means idle. Each leg goes round obstacles like `act.moveTo` a point does (straight when the line to the waypoint is clear). |
| `act.emitNoise(loudness, kind?)` | Make a noise once, on the tick the rule takes over; it does not repeat while the same rule keeps winning (it sounds again only after another rule has won in between). `loudness` is in metres at `hearing.scale` 1: `emitNoise(30)` reaches NPCs within 30 m. A number or a number prop. Anything that can hear it notices it next tick as `sensed.heard` / `sensed.noisePos`. `kind` says what it is (`"noise"` by default, or `"footstep"`, `"gunshot"`, `"alarm"`, `"damage"`); a hearer can scale each kind. Also emits an `npc.noise` event. |
| `act.custom(name, arg?)` | The slow path: every tick this rule wins, the engine emits an `npc.custom` event with `name` and the number. See Events and Limits. |
| `act.drive(speed, opts?)` | Hand the move slot to the type's `movement.controller`, which steers every tick at up to `speed` (a cap: the controller may go slower, e.g. in bends or when arriving). `opts`: `route` and `index` (follow-path), `to` (crowd goal; optional for flock). See Controllers. |

Arguments that must be a fact (`into`, `index`) have to be written as `fact.<name>`, not a computed value.

## Action slots, priority and cooldowns

An NPC can do several things at once: walk, look at something, swing, shout. A rule fills one of four **slots**:

| Slot | What it is | Actions it takes |
|---|---|---|
| `move` (default) | the legs: where it goes | every action (so an old type is unchanged) |
| `aim` | where the body points | `face`, `idle` |
| `upper` | the upper body: attacks, animation | `attack`, `playAnim`, `custom`, `idle` |
| `voice` | sound | `emitNoise`, `custom`, `idle` |

Each slot has **one action at a time** and picks it the same way: the first rule of that slot, in list order, whose
`when` is true. A rule says which slot it fills with `.slot("aim")`. The slots do not compete: in the same tick the
move slot's winner and the aim slot's winner both act. A slot with no matching rule is idle (only `move` has an
`otherwise`). `aim` wins over `move` for the heading: the NPC walks one way and looks another. The decision log
records the slot of each change.

- `.priority(n)`: a held rule (`hold`) is normally never interrupted. A matching rule of **higher priority** in the same
  slot interrupts it (priority is a whole number, default 0; list order still decides among rules of equal priority).
- `.cooldown(ticks)`: after the rule takes over, it cannot take over again for `ticks` ticks (60 a second). A rule that
  keeps winning is not stopped by its own cooldown; it limits how often it can start over, so a shout does not fire every
  time the target steps in and out of view.
- Fact writes from different slots all apply, move first, then aim, upper, voice; a later slot's write to the same fact wins.

```ts
// A rule in another slot reads like any other. `me.doingIn("aim", "face")` is true while the aim slot is facing.
rule("shout").when(sensed.seen).do(act.emitNoise(30)).slot("voice").cooldown(300)
```

A type that uses only the move slot pays nothing for any of this and behaves exactly as before.

<!-- ease:gunner -->
Worked example, the gunner: patrols, keeps its eyes on the intruder, fires, and calls out at most every five seconds.

```ts
import { npc, t } from "@argus/gameplay"

/** Moves, aims, shoots and shouts independently: walks a route, keeps its eyes on the intruder, fires every half second, calls out at most every 5 s. */
export default npc({
  name: "gunner",
  facts: { leg: t.int() },
  props: { route: t.route(), walk: t.num(1.5) },
  rules: ({ fact, prop, sensed, act, rule }) => [
    rule("fire").when(sensed.seen.and(sensed.targetDist.lt(12))).do(act.attack(sensed.target, 12, 30)).slot("upper"),
    rule("shout").when(sensed.seen).do(act.emitNoise(30)).slot("voice").cooldown(300),
    rule("aim").when(sensed.seen).do(act.face(sensed.target)).slot("aim"),
    rule("patrol").when(prop.route.len.gt(0)).do(act.patrol(prop.route, fact.leg, prop.walk)),
  ],
  senses: { sight: { range: 15, fov: 120 } },
  otherwise: ({ act }) => act.idle(),
})
```
<!-- /ease:gunner -->

## Controllers: racing, flocking, crowds

Some movement is not a list of discrete actions but continuous control every tick: a car following a road, a bird in a
flock, a shopper in a crowd. A type picks one **controller** in `movement`, and a rule hands the move slot to it with
`act.drive(speed, ...)`. The controller steers every tick (the rules still think on their own schedule); any other rule
that wins takes over (flee, attack, stand still), and a `drive` rule wins it back. Everything is deterministic and
allocation-free; flock and crowd look only at the 8 nearest neighbours at most.

All parameters are numbers in the type (not props), every one optional; the defaults are shown. An unknown parameter is
a compile error that lists the right ones.

**`follow-path`** (racing, trains, patrol cars): follows a route prop. It aims at a point `lookahead + lookaheadGain *
speed` metres ahead of the nearest point on the route, and holds the speed that lateral `grip` allows in the bends ahead
(`sqrt(grip / curvature)`, braking early, never below `minSpeed`). `act.drive(speed, { route, index })`: `index` is an
int fact that remembers which segment it is on, so a route that crosses itself works. The route closes into a loop
unless `loop: false` (then it slows to a stop over `arrive` metres at the end). If it is knocked more than `recover`
metres off, it finds its place again.

`{ kind: "follow-path", lookahead: 6, lookaheadGain: 0.3, grip: 12, minSpeed: 1.5, loop: true, recover: 20, window: 6, arrive: 2 }`

<!-- ease:racer -->
```ts
import { npc, t } from "@argus/gameplay"

/** A racer: follows a looped track prop at a speed set by the bends, in a pack. */
export default npc({
  name: "racer",
  facts: { seg: t.int() },
  props: { track: t.route(), pace: t.num(14) },
  rules: ({ fact, prop, act, rule }) => [rule("race").when(prop.track.len.gt(1)).do(act.drive(prop.pace, { route: prop.track, index: fact.seg }))],
  movement: { speed: 16, accel: 10, turnRate: 540, controller: { kind: "follow-path", lookahead: 6, grip: 14 } },
  senses: {},
  otherwise: ({ act }) => act.idle(),
})
```
<!-- /ease:racer -->

**`flock`** (birds, fish, herds): separation, alignment, cohesion, optionally leaning toward a goal. `act.drive(speed)` or
`act.drive(speed, { to: point })`. With no `to`, the flock keeps the heading it has and turns only to follow its neighbours.
With `to`, the goal is one more pull, weighted by `goal`: separation, alignment and cohesion still act, so a goal point
does **not** reliably steer a flock round something. To keep a flock away from an entity (a predator, a player), put a
rule above the drive rule that wins when it is near, and flee:

```ts
rule("scatter").when(sensed.seen.and(sensed.targetDist.lt(6))).do(act.fleeFrom(sensed.target, 8, 7)),
rule("flock").when(true).do(act.drive(prop.cruise)),
```

`{ kind: "flock", neighbourRadius: 6, maxNeighbours: 8, separationRadius: 1.6, separation: 1.5, alignment: 1, cohesion: 1, goal: 0.6, arrive: 3 }`

<!-- ease:flocker -->
```ts
import { npc, t } from "@argus/gameplay"

/** A bird in a flock: stays with its neighbours, and runs from anything it sees. */
export default npc({
  name: "flocker",
  props: { cruise: t.num(3), panic: t.num(5) },
  rules: ({ prop, sensed, act, rule }) => [
    rule("scatter").when(sensed.seen).do(act.fleeFrom(sensed.targetPos, 15, prop.panic)),
    rule("flock").when(true).do(act.drive(prop.cruise)),
  ],
  movement: { speed: 5, controller: { kind: "flock", neighbourRadius: 6, cohesion: 1, alignment: 1, separation: 1.5 } },
  senses: { sight: { range: 12 } },
  otherwise: ({ act }) => act.idle(),
})
```
<!-- /ease:flocker -->

**`crowd`** (pedestrians, queues, evacuations): goal seeking with avoidance. Each neighbour is predicted `horizon` seconds
ahead; a pass closer than `separationRadius` is steered around, and two walkers meeting head on pick opposite sides.
`act.drive(speed, { to: point })` (the goal is required).

`{ kind: "crowd", neighbourRadius: 6, maxNeighbours: 8, separationRadius: 1.6, avoid: 2, horizon: 1.5, arrive: 1 }`

<!-- ease:pedestrian -->
```ts
import { npc, t } from "@argus/gameplay"

/** A pedestrian: walks to a goal point (a prop) and steers around other walkers on the way. */
export default npc({
  name: "pedestrian",
  props: { goal: t.route(), pace: t.num(1.4) },
  rules: ({ prop, act, rule }) => [rule("walk").when(prop.goal.len.gt(0)).do(act.drive(prop.pace, { to: prop.goal.at(0) }))],
  movement: { speed: 2, controller: { kind: "crowd", separationRadius: 1.4, avoid: 2 } },
  senses: {},
  otherwise: ({ act }) => act.idle(),
})
```
<!-- /ease:pedestrian -->

`drive` without a `movement.controller` is a compile error. One controller per type; a rule can still switch to any other
action. Do not `hold` a follow-path rule for long (the segment fact is updated when the NPC thinks).

## Bosses: phases and a timeline

A boss fight is rules and two facts; there is no boss concept in the engine. `api.boss({ phases })` returns ordinary
rules you spread into your list (put it after the rules that should always win, such as "dead"), and `bossFacts([...phase
names])` gives the two facts it needs. **Phases** are tried in order: the first whose `when` is true is the current one
(leave `when` out on the last as the fallback), so health thresholds are just conditions. Inside a phase, a **timeline**
of `step(name, ticks, action, sets?)` runs in order and loops: telegraph, attack, recover. A step lasts exactly `ticks`
ticks. Entering a phase starts its timeline at step 0. Rule names are `"<phase>: <step>"` in the decision log.

<!-- ease:warden -->
```ts
import { bossFacts, npc, step, t } from "@argus/gameplay"

/** A boss in two phases: calm above half health, enraged below. Each phase loops telegraph, attack, recover. */
export default npc({
  name: "warden",
  facts: { hp: t.num(100), ...bossFacts(["calm", "enraged"]) },
  rules: ({ fact, sensed, act, rule, boss }) => [
    rule("dead").when(fact.hp.le(0)).do(act.idle()),
    ...boss({
      phases: [
        {
          name: "calm",
          when: fact.hp.gt(50),
          timeline: [
            step("telegraph", 60, act.face(sensed.targetPos)),
            step("attack", 20, act.attack(sensed.target, 3, 10)),
            step("recover", 90, act.idle()),
          ],
        },
        {
          name: "enraged",
          timeline: [step("telegraph", 30, act.face(sensed.targetPos)), step("attack", 40, act.attack(sensed.target, 4, 8)), step("recover", 30, act.idle())],
        },
      ],
    }),
  ],
  senses: { sight: { range: 25, fov: 360 } },
  otherwise: ({ act }) => act.idle(),
})
```
<!-- /ease:warden -->

The timeline clock is `fact.bossClock` (ticks into the loop) and the phase is `fact.bossPhase`, if another rule needs
them. A step's `sets` are written every tick that step runs: at most 2 fact columns for the first step, 3 for the others.

## Position queries: cover, flank, flee, search, ambush

A query answers "where" with a point. One query per kind of place, written as data: generate candidate points, drop the
ones that fail the filters, add up the score terms, and the best one is the answer. Rules read the answer as a point,
`query.cover`, anywhere a point goes (`act.moveTo`, `sets`, `act.findPoint.fromQuery`). Reading `query.x` in a rule's
action asks the query while that rule wins; `.asks(query.x)` asks without reading it.

The engine answers asks in batches, in a fixed order (oldest request first, then query, then slot), up to
`queryBudget` per tick for the whole type (default 32; a count, never a time). So an answer can arrive a tick or more
after the ask. Test `query.x.found` (has an answer), `query.x.pending` (awaited) or `query.x.age` (ticks since). Until
it is found, `query.x` reads as the NPC's own position.

Parts: generators `q.ring(r, count)`, `q.around(r0, r1, count)`, `q.grid(half, step)`, `q.arc({ from, to, r0, r1, count,
mirror })`; filters `q.clear()`, `q.reachable(metres)`, `q.hidden(of)`, `q.visible(of)`, `q.range(min, max, of)`,
`q.unreserved()`; scores `q.near`, `q.far`, `q.dist(of, metres)`, `q.path()`, `q.jitter()`. `of` is `"self"`, `"target"`
(the query's `from`, default `sensed.targetPos`) or `"center"`. `hidden`/`visible` cast rays against the same static
world as sight, at most `rays` (default 16) per answer, tried best score first.

**Reservation.** `reserve: { radius, ttl }` holds the answer for `ttl` ticks. With `q.unreserved()` in the filter, no
other NPC of the type is given a point within `radius` of a held one. Contested spots go to the earlier grant (slot
order). A reservation is released when the NPC stops asking, when it expires, or when the NPC is removed.

```ts
queries: ({ memory, sensed }) => ({
  cover: {                                   // take cover: hidden from the threat, near, unclaimed
    from: sensed.targetPos,
    gen: q.ring(12, 16),
    filter: [q.reachable(30), q.hidden("target"), q.unreserved()],
    score: [q.path()],
    reserve: { radius: 1.5, ttl: 300 },
  },
  flank: {                                   // to the side of the threat, 60 to 100 degrees off its facing
    center: sensed.targetPos,
    gen: q.arc({ from: 60, to: 100, r0: 9, r1: 9, count: 8, mirror: true }),
    filter: [q.reachable(40)],
    score: [q.path()],
  },
  flee: {                                    // the farthest reachable point from the threat
    from: sensed.targetPos,
    gen: q.ring(14, 16),
    filter: [q.reachable(30)],
    score: [q.far("target")],
  },
  ambush: {                                  // out of sight, about 9 m from the target
    center: sensed.targetPos,
    gen: q.around(8, 14, 24),
    filter: [q.reachable(40), q.hidden("target")],
    score: [q.dist("target", 9)],
  },
  search: {                                  // spread searchers around where it was last seen
    center: memory.lastSeenPos,
    gen: q.around(0, 10, 12),
    filter: [q.reachable(25), q.unreserved()],
    score: [q.jitter()],
    reserve: { radius: 3, ttl: 600 },
  },
}),
rules: ({ prop, sensed, memory, query, act, rule }) => [
  rule("take cover").when(sensed.seen).do(act.moveTo(query.cover, 5)),
  rule("search").when(memory.has).do(act.moveTo(query.search, 2)),
],
```

`center` can be any point expression. A full example is `packages/gameplay/src/npc/examples/tactician.ts`. Other
options: `facing` (heading for `arc`), `maxAge` (re-ask after this many ticks, 120), `retry` (re-ask after finding
nothing, 30), `eyeHeight`, `height`. A type with no queries carries no query state and its hashes do not change.

## Factions, shared facts, groups, schedules and events

Everything here is optional. A type that declares none of it runs, hashes and costs exactly as before.

```ts
export default npc({
  name: "sentry",
  factions: {
    names: ["guard", "bandit", "town"], default: "town", player: "town",
    relations: [{ a: "guard", b: "bandit", v: -1 }, { a: "guard", b: "town", v: 1 }], // negative hostile, 0 neutral, positive friendly
  },
  facts: { alarm: t.faction(t.num(0)), kills: t.world(t.int(0)) },
  props: { post: t.place([0, 0, 0]), tavern: t.place([10, 0, 5]) },
  senses: { sight: { range: 20 }, only: "hostile" },          // perception filters targets by relation
  schedule: { dayTicks: 2400, startHour: 8, entries: [{ from: 6, state: "work", at: "post" }, { from: 18, state: "relax", at: "tavern" }, { from: 23, state: "sleep" }] },
  group: ({ group }) => ({
    orders: ["patrol", "left", "right"],
    rules: [{ name: "engage", when: group.seeing.gt(0), orders: ["left", "right"], by: "rank", hold: 20 }],
    otherwise: { orders: ["patrol"] },
  }),
  rules: ({ act, rule, event, group, schedule, merge, isHostile, sensed }) => [
    rule("hurt").when(event.damaged).do(act.face(event.damagedBy), { alarm: merge.max(1), kills: merge.add(event.damage) }),
    rule("flank").when(group.order.is("left").and(isHostile(sensed.target))).do(act.moveTo(group.hostilePos, 4)),
    rule("work").when(schedule.now.is("work")).do(act.moveTo(schedule.at, 1.5)),
  ],
  otherwise: ({ act }) => act.idle(),
})
```

**Scene props.** A type with `factions` takes the scene prop `faction` (a name from `factions.names`, or its index); a
type with a `group` block takes `group` (any whole number: NPCs of the type with the same number form a group) and
`role` (a whole number, default 0). They are reserved: do not declare props with these names.

**Shared facts (`t.faction(..)`, `t.world(..)`).** One value per faction, or one for the world, instead of one per
character. Every character reads the value as of the start of the tick. A rule changes it with `merge.set/add/max/min`;
the writes of all characters are collected and merged between ticks in slot order, so a tick's result never depends on
thread timing (`set`: the last writer in slot order wins; `add`, `max`, `min` are order-free except for float rounding of
`add`, which is also in slot order). A plain value means `set`. Shared facts are numbers (a bool, int or enum is stored as a number); points and
entities cannot be shared. Own facts take plain values only.

**Factions and relations.** `relation(entity)` is the number between this character's faction and the entity's
(players are in `factions.player`; NPCs of other types are matched by faction *name*; unknown = 0). `isHostile(e)` is
`relation < 0`, `isFriendly(e)` is `> 0`. `sensed.targetRelation`, `sensed.hostile`, `sensed.friendly` and `me.faction` /
`me.role` read the same. A faction is friendly to itself unless a relation says otherwise. `senses.only: "hostile" |
"friendly" | "not-friendly"` makes perception skip the other targets, so a nearer friendly does not hide a hostile. At most 32 factions.

**Groups.** The same rule contract one level up. Once per tick, per group, in ascending group-id order, before the
characters think, the `group` rules run on the group's aggregates and the first match picks what the members do (`hold`
keeps a pick at least that many ticks). Group rule expressions may read `group.size`, `group.seeing` (members that see a
hostile), `group.hostileDist`, `group.hostilePos` (the seen hostile nearest to the centroid), `group.centroid` and the
shared facts, not a character's own facts. A rule gives its members `orders` (names, at least one) dealt out by `by: "rank"`
(their place in the group, the default) or `by: "role"` (their `role` prop), cycling. Members read the result as
`group.order` (compare with `.is("name")`) and `group.rank`. The group's faction is its first member's. At most 64 orders.
A rule over a large group costs one pass over the members per tick, serial; thousands of groups are fine.

**Schedules.** `schedule: { dayTicks, startHour, entries }`. A world clock turns ticks into an hour of the day (0 to 24;
`dayTicks` ticks per day, at least 24, default 86400, `startHour` the hour at tick 0). The entry with the latest `from` not
after the hour is in force (before the first entry, the last of the day). `schedule.now` is that entry's state (compare
with `.is("work")`), `schedule.at` its place (a `t.place` prop, else the character's home) and `schedule.hour` the hour.
Scripts can move the clock with `npc.clock` (below).

**Events.** `event.damaged`, `event.damage`, `event.damagedBy`, `event.talked`, `event.talkedBy`, `event.witnessed`,
`event.witnessedWho`, `event.witnessedPos` are readable by the target for exactly one tick, and wake a character that is
between its think turns (`think_every`), so it reacts on the tick the event is consumed. Two damage events before one tick sum
their damage and keep the later source; a second talk or witness replaces the first.

**What scripts call.** All go through `ctx.events.emit`, and take effect on the next tick, in call order. An entity is its
scene id (or an entity index). Bad data is ignored.

```ts
ctx.events.emit("npc.damaged", { npc: "guard1", by: "player", amount: 4 })     // onDamaged
ctx.events.emit("npc.talk", { npc: "guard1", by: "player" })                   // onTalk
ctx.events.emit("npc.witnessed", { npc: "guard2", who: "player", pos: [1, 0, 2] }) // onWitnessed
ctx.events.emit("npc.reputation", { fact: "alarm", faction: "guard", op: "add", value: 5 }) // op: set (default), add, max, min
ctx.events.emit("npc.reputation", { fact: "alarm", faction: "guard", op: "get" })           // answered by npc.reputation.value
ctx.events.emit("npc.relation", { a: "guard", b: "town", value: -1 })          // change a relation (both ways)
ctx.events.emit("npc.clock", { hour: 12, dayTicks: 7200 })                     // either field is optional
ctx.events.on("npc.reputation.value", (ev) => { /* ev.data: { fact, faction, value (null if unknown) } */ })
```

Reputation is a faction fact, so any rule can read `fact.alarm` for its own faction. `npc.reputation` and `npc.relation` apply to every NPC
type that declares that fact or those faction names. Rust callers read the same values with `World::npc_reputation(fact, faction)`.
A `get` is answered after the tick that applied the changes queued with it.

## Events your scripts receive

The engine turns three actions into game events. A script listens with `ctx.events.on`. `ev.by` is the id
of the NPC entity that did it:

```ts
ctx.events.on("npc.attack", (ev) => { /* ev.data.target: the scene id of the entity that was attacked (null if it is gone); ev.by: the attacker */ })
ctx.events.on("npc.noise", (ev) => { /* ev.data.loudness, ev.data.pos: [x, y, z], ev.data.kind: "noise", "footstep", ...; ev.by: the maker */ })
ctx.events.on("npc.custom", (ev) => { /* ev.data.name, ev.data.arg: the number, ev.data.npc: the NPC's scene id, ev.data.target: the seen target's scene id or null; ev.by: the NPC */ })
```

Use `npc.attack` to apply damage. The NPC type itself never changes health.

A script can make a noise every NPC type hears on the next tick (an explosion, a thrown bottle, an alarm bell):

```ts
ctx.events.emit("npc.stimulus", { pos: [x, y, z], loudness: 30, kind: "alarm" }) // kind is optional, default "noise"
```

A bad `pos`, a negative `loudness` or an unknown `kind` is ignored.

<!-- ease:guard -->
## Worked example: the guard

Patrols a route. Attacks an intruder who is close. Otherwise shouts once, then chases. Guards that hear the
shout run to where it came from. When the intruder is gone, it stands down and goes back to patrolling.

```ts
import { npc, t } from "@argus/gameplay"

/** A guard on patrol that shouts when it sees an intruder; guards that hear it run to the noise. */
export default npc({
  name: "guard",
  facts: {
    leg: t.int(),
    shouted: t.bool(false),
    alarm: t.point(),
    alarmed: t.bool(false),
  },
  props: { route: t.route(), walk: t.num(1.5), run: t.num(4), attackRange: t.num(1.5) },
  rules: ({ fact, prop, sensed, me, act, rule }) => [
    rule("attack")
      .when(sensed.seen.and(sensed.targetDist.lt(prop.attackRange)))
      .do(act.attack(sensed.target, prop.attackRange, 30)),
    rule("shout")
      .when(sensed.seen.and(fact.shouted.not()))
      .do(act.emitNoise(40), { shouted: true })
      .hold(10),
    rule("chase").when(sensed.seen).do(act.moveTo(sensed.target, prop.run), { alarmed: false }),
    rule("hear alarm")
      .when(sensed.heard)
      .do(act.moveTo(sensed.noisePos, prop.run), { alarm: sensed.noisePos, alarmed: true }),
    rule("go to alarm")
      .when(fact.alarmed.and(me.pos.distXZ(fact.alarm).gt(1.5)))
      .do(act.moveTo(fact.alarm, prop.run)),
    rule("search done").when(fact.alarmed).do(act.idle(), { alarmed: false }),
    rule("stand down").when(sensed.seen.not().and(fact.shouted)).do(act.idle(), { shouted: false }),
    rule("patrol").when(prop.route.len.gt(0)).do(act.patrol(prop.route, fact.leg, prop.walk)),
  ],
  senses: { sight: { range: 18, fov: 110 }, hearing: {} },
  otherwise: ({ act }) => act.idle(),
})
```
<!-- /ease:guard -->

<!-- ease:villager -->
## Worked example: the villager

Flees when it sees a threat (holding the decision for 30 ticks), walks back to where it was placed (`me.home`), then calms down.

```ts
import { npc, t } from "@argus/gameplay"

/** Flees when it sees a threat, then walks home and calms down. */
export default npc({
  name: "villager",
  facts: {
    mode: t.enum(["calm", "fleeing"]),
  },
  props: { runSpeed: t.num(4), walkSpeed: t.num(1.5), fearDistance: t.num(14) },
  rules: ({ fact, prop, sensed, me, act, rule }) => [
    rule("flee")
      .when(sensed.seen)
      .do(act.fleeFrom(sensed.targetPos, prop.fearDistance, prop.runSpeed), { mode: "fleeing" })
      .hold(30),
    rule("go home")
      .when(fact.mode.is("fleeing").and(me.pos.distXZ(me.home).gt(1)))
      .do(act.moveTo(me.home, prop.walkSpeed)),
    rule("calm down").when(fact.mode.is("fleeing")).do(act.idle(), { mode: "calm" }),
  ],
  senses: { sight: { range: 12 } },
  otherwise: ({ act }) => act.idle(),
})
```
<!-- /ease:villager -->

<!-- ease:walker -->
## Worked example: the walker

A platformer enemy. Walks along x. When a wall is ahead it turns round.

```ts
import { npc, t } from "@argus/gameplay"

/** A platformer enemy: walks along x, turns around when blocked. */
export default npc({
  name: "walker",
  facts: { dir: t.num(1) },
  props: { speed: t.num(2) },
  rules: ({ fact, prop, sensed, me, act, rule }) => [
    rule("turn around")
      .when(sensed.blocked)
      .do(act.moveTo(me.pos.offset(fact.dir.neg().mul(100), 0, 0), prop.speed), { dir: fact.dir.neg() })
      .hold(20),
  ],
  senses: { wallAhead: 0.6 },
  otherwise: ({ fact, me, prop, act }) => act.moveTo(me.pos.offset(fact.dir.mul(100), 0, 0), prop.speed),
})
```
<!-- /ease:walker -->

<!-- ease:watcher -->
## Worked example: the watcher

Uses the perception depth: it weighs what it sees by angle (with hysteresis on its alert state), tells a gunshot from other
noises, shouts an alarm when it has several intruders in view, chases whoever it is most aware of, and searches where it last
saw someone.

```ts
import { npc, t } from "@argus/gameplay"

/** A sentry with a long memory: weighs what it sees by angle, tells shots from footsteps, goes to where it last saw someone. */
export default npc({
  name: "watcher",
  facts: { shots: t.int() },
  props: { walk: t.num(1.5), run: t.num(4) },
  senses: {
    sight: { range: 22, fov: 110 },
    hearing: { kinds: { footstep: 0.4, gunshot: 2 } },
    awareness: { angleBias: 0.6, suspiciousExit: 0.15, alertedExit: 0.5 },
    memory: { forget: 600 },
    seenMax: 4,
    maxRays: 4,
  },
  rules: ({ fact, prop, sensed, memory, me, act, rule }) => [
    rule("sound the alarm").when(sensed.alerted.and(sensed.seenCount.gt(1))).do(act.emitNoise(30, "alarm")).hold(120),
    rule("chase the most aware").when(sensed.alerted.and(sensed.mostAware.isSome())).do(act.moveTo(sensed.mostAware, prop.run)),
    rule("check the shot").when(sensed.heard.and(sensed.heardKind.is("gunshot"))).do(act.moveTo(sensed.noisePos, prop.run), { shots: fact.shots.add(1) }),
    rule("search where last seen")
      .when(memory.has.and(me.pos.distXZ(memory.lastSeenPos).gt(1.5)))
      .do(act.moveTo(memory.lastSeenPos, prop.walk)),
    rule("watch the suspect").when(sensed.suspicious.and(sensed.seen)).do(act.face(sensed.target)),
  ],
  otherwise: ({ act }) => act.idle(),
})
```
<!-- /ease:watcher -->

<!-- ease:packs -->
## Starter packs

Ready-made NPC types in `packages/gameplay/src/npc/packs/`. Copy one into `behaviors/`, change the props, or read it as a
worked example.

- `platformer-enemy.ts`: walks along x, turns at walls and ledges (a drop is blocked ground), hurts what it touches.
- `patrol-guard.ts`: walks a route, shouts once and chases an intruder, attacks up close, runs to a shout, goes back on patrol.
- `stealth-guard.ts`: suspicious before alerted, watches, raises an alarm, searches where it last saw the intruder, answers an alarm.
- `racer.ts`: follows a looped track with the follow-path controller, slower in the bends.
- `flock.ts`: a flock, herd or school: separation, alignment, cohesion, an optional goal, scatters from a threat.
- `squad-soldier.ts`: four slots at once (move, aim, upper, voice) and orders from its group (suppress, flank, hold): fires while aiming, flanks with a position query, takes reserved cover, searches. The scene gives squadmates the same `group` number.
- `schedule-villager.ts`: a timetable on the world clock (work, market, home), flees from a threat in between. The scene sets the `workplace` and `market` places.
<!-- /ease:packs -->

## Snapshots and rollback

A world snapshot (`GET /snapshot`, `World::snapshot_state`) holds every NPC type's simulation, and a
restore resumes it exactly: the same positions, decisions, facts, reservations and events as a run that
never restored, at any thread count. It also holds what scripts asked of the NPCs since their last tick
(`npc.damaged`, `npc.stimulus` and the rest), which the next tick applies. See docs/API.md, "NPC types in
a snapshot". A snapshot from before this restores the NPCs as a fresh load starts them.

## Common mistakes

The compiler runs when `npc({...})` is evaluated (in node for your tests, in the engine when it loads the module), so a
mistake throws right there: an Error whose message is every problem it found, joined by `; `, each naming the rule and
the field.

| Message (shortened) | What went wrong | Fix |
|---|---|---|
| `rule r field when: unknown fact x (declared: a, b)` | A fact or prop name is misspelt or not declared. | Declare it in `facts` or `props`. |
| `field when: value: expected bool, got num` | A `when` must be true or false, but you gave a number. | Compare it: `fact.fear.gt(0.5)`. |
| `rule r field do.a: unknown action primitive x` | The action name is not one of the list. | Use an `act.<name>` from the table. |
| `rule r field do.speed: moveTo needs speed` | An action argument is missing. | Give every argument. |
| `rule r field sets: a rule can write at most 4 fact columns ...` | Too many writes, counting a point as 3 and counting an action's own writes (`findPoint`, `patrol`). | Split the work across two rules. |
| `field sets.mode: bored is not a member of enum mode (calm, afraid)` | The enum has no such value. | Use one of the listed values. |
| `field sets.flag: value: expected bool, got num` | A set value has the wrong type. | Give an expression of the fact's type. |
| `eq cannot compare points` | Points are never equal-tested. | Compare `distTo(...)` with a small number. |
| `prop path is a route: use .at(i) or .len` | A route prop was used as a number. | `prop.path.len`, `prop.path.at(0)`. |
| `symbol "x" has no meaning here` | A string was used where it is not a name from an enum. | Only use strings to compare or set an enum fact. |
| `field senses.sight.fov: must be a number > 0 and <= 360` | A sense or movement number is out of range. | Fix the number. |
| `expression is too deep` | One expression nests more than 48 values. | Store part of it in a fact. |
| `rule names must be non-empty and unique` | Two rules share a name. | Rename one. |
| `rule r field slot: unknown slot legs (move, aim, upper, voice)` | A misspelt slot. | Use one of the four. |
| `rule r field do.a: slot aim takes idle, face (got moveTo)` | An action in a slot that cannot do it. | Put the rule in the `move` slot, or use an action the slot takes. |
| `rule r field do.a: drive needs movement.controller` | `drive` with no controller declared. | Add `movement: { controller: { kind: "flock" } }` (or follow-path, crowd). |
| `field movement.controller.lookahead: lookahead is not a parameter of flock (...)` | A parameter the controller does not have. | Use one from the list it prints. |
| `npc: sets: unknown fact zzz` (thrown by the builder) | A `sets` key is not a declared fact. | Use a declared fact name. |
| `npc: number literal must be finite` | `NaN` or infinity in a number. | Use a real number. |

Mistakes that do not give an error:

- **A rule above the one you wanted always matches.** Order matters. Put the narrow rule first, the broad one
  later.
- **`senses` written but sight left out.** The NPC is blind. Add `sight: {}`.
- **Hold too long.** A held rule ignores everything else until it ends.
- **`attack` does not move the NPC.** Pair it with a `moveTo` rule below it.
- **A rule in the `aim` slot does not walk.** `face` in the aim slot only turns the body; the move slot keeps moving.
- **A `priority` on the held rule itself.** A hold is interrupted only by a rule of strictly higher priority; with every rule at the default 0 nothing interrupts a hold.
- **`drive` with a crowd controller and no `to`.** The goal point is required.

## Limits
- At most 32 factions and 64 group orders. Shared facts hold numbers. A group rule cannot read a character's own facts or senses.
- Four action slots; each takes only the actions listed under Action slots. One controller per type.

- One NPC type per module (one `export default npc(...)`).
- The static world (what blocks sight and movement) is rebuilt when a `meta.nav.live` navmesh rebuild is
  installed, so a wall that appears at runtime blocks sight afterwards. NPC state is kept.
- At most 4 fact columns written per rule. A point is 3.
- At most 65534 rules, and an expression can hold 48 values at once.
- A custom action is the slow path. At most 64 run per tick across the whole world (in character order);
  the rest wait for the next tick. Use it only for something the primitives cannot do. Everything else
  stays on the fast path.
- Sense values are numbers, not props.
- The seen list holds at most 8 entities (`seenMax`), chosen from the 16 nearest candidates in view range and cone.
- The decision log keeps the last 16 changes per NPC.
- A query generates at most QUERY_CAP candidates, spends at most `rays` line-of-sight rays per answer and is
  answered at most `queryBudget` times per tick per type.

