# Fightimer — AI preset-authoring skill

You are authoring a **preset timer (a "routine") for Fightimer**, a combat-sports round timer and drill-callout coach (boxing, wrestling, MMA). A routine is rounds of work/rest plus **cues** — technique words the app barks out loud mid-round ("SPRAWL!", "JAB!") like a corner coach.

This document is self-contained: it holds the exact schema, every legal command id, the share-code encoding, worked examples, and a self-check list. Follow it and your output will import into the app unchanged.

- Routine schema version: **1**. Doc last synced: 2026-07-09.
- Sources of truth (in the app repo): `src/engine/routine.ts` (schema + validation) and `src/packs/index.ts` (command catalog). If you are working inside the repo, prefer `npm run routine -- catalog | validate | encode` over doing any of this by hand.

## What to output

Produce **one** of these, and nothing else around it:

1. **An `FTR1.` share code** (preferred). The user pastes it into the app: **Presets → "Import a code"** (a Pro feature). Only emit a code if you can execute code to compute it (see *Encoding* below) — never hand-compute base64.
2. **The routine as a fenced JSON block**, if you cannot run code. Tell the user it can be converted to a share code with the repo's `npm run routine -- encode` tool.

Conventions: `id` should be `ai-<slug>` (lowercase, hyphens); `name` is what fighters see — keep it **≤ 40 characters**.

## The Routine schema

```json
{
  "schemaVersion": 1,
  "id": "ai-example",
  "name": "Example 3x3",
  "rounds": [{ "workSeconds": 180, "restSeconds": 60 }],
  "cues": [
    { "kind": "fixed", "roundIndex": 0, "atSec": 30, "commandId": "jab" },
    { "kind": "random", "roundIndex": 0, "fromSec": 60, "toSec": 170, "commandPool": ["sprawl", "shoot"], "count": 5, "minGapSec": 6 }
  ]
}
```

- `rounds` — one entry per round, in order. All times are seconds (integers preferred).
- `cues` — anchored to a round via `roundIndex` (**0-based**), never to absolute session time. Times inside a cue are seconds **from that round's work start**.
  - `fixed`: one specific command at `atSec`.
  - `random`: the app draws `count` commands from `commandPool` at random times inside `[fromSec, toSec]`, keeping at least `minGapSec` seconds between them. Placement is deterministic per session seed — same drill, different session, different timing.

### Hard rules — the app rejects the routine if any fail

1. `schemaVersion` is exactly `1`.
2. `id` and `name` are non-empty strings.
3. At least one round; every `workSeconds > 0`; every `restSeconds >= 0`.
4. Every cue's `roundIndex` is an integer index of an existing round.
5. Every random zone: `fromSec < toSec`, integer `count >= 1`, `minGapSec >= 0`, non-empty `commandPool`.
6. Every `commandId` and every `commandPool` entry is an id from the catalog below. **The app drops unknown ids silently — the cue simply never fires.** Never invent ids.

### Soft rules — accepted, but the compiler adjusts

- Cue times clamp into `[1, workSeconds − 1]` of their round; place cues inside that window yourself.
- Two cues landing within **1.5 s** of each other: the later one is dropped (can't both be heard).
- A random zone that can't fit `count` calls at `minGapSec` spacing gets its count reduced. Sanity check: `count ≤ (toSec − fromSec) / minGapSec + 1`.

## Command catalog

These are ALL the legal command ids. `Says` is what the app shouts.

### Wrestling — pack `wrestling`

| Command id | Says |
|---|---|
| `sprawl` | SPRAWL |
| `shoot` | SHOOT |
| `double` | DOUBLE |
| `single` | SINGLE |
| `high-c` | HIGH-C |
| `level-change` | LEVEL CHANGE |
| `snap-down` | SNAP DOWN |
| `down-block` | DOWN BLOCK |
| `circle` | CIRCLE |
| `hand-fight` | HAND FIGHT |
| `reshot` | RESHOT |
| `post` | POST |

### Boxing — pack `boxing`

| Command id | Says |
|---|---|
| `jab` | JAB |
| `cross` | CROSS |
| `hook` | HOOK |
| `uppercut` | UPPERCUT |
| `slip` | SLIP |
| `roll` | ROLL |
| `pivot` | PIVOT |
| `double-jab` | DOUBLE JAB |
| `one-two` | ONE-TWO |
| `body-shot` | BODY SHOT |
| `duck` | DUCK |
| `step-back` | STEP BACK |
| `check-hook` | CHECK HOOK |
| `overhand` | OVERHAND |

### MMA — pack `mma`

| Command id | Says |
|---|---|
| `jab` | JAB |
| `double-jab` | DOUBLE JAB |
| `cross` | CROSS |
| `hook` | HOOK |
| `uppercut` | UPPERCUT |
| `low-kick` | LOW KICK |
| `body-kick` | BODY KICK |
| `head-kick` | HEAD KICK |
| `teep` | TEEP |
| `knee` | KNEE |
| `elbow` | ELBOW |
| `sprawl` | SPRAWL |
| `shoot` | SHOOT |
| `level-change` | LEVEL CHANGE |
| `clinch` | CLINCH |
| `circle-out` | CIRCLE OUT |
| `check` | CHECK |

Ids may be mixed freely across packs in one routine (`jab` and `sprawl` in the same pool is fine).

## Encoding — the `FTR1.` share code

`FTR1.` + base64url(UTF-8 bytes of the routine JSON). Base64url alphabet `A–Z a–z 0–9 - _`, **no padding**. A valid code matches `^FTR1\.[A-Za-z0-9_-]+$`.

If you can execute code, compute it — never by hand:

```python
import base64, json
code = "FTR1." + base64.urlsafe_b64encode(
    json.dumps(routine, separators=(",", ":")).encode()).decode().rstrip("=")
```

```js
const code = 'FTR1.' + Buffer.from(JSON.stringify(routine), 'utf8')
  .toString('base64url');
```

## Worked examples

### 1 — "10 rounds of 3:00/1:00 boxing; punches early rounds, defense mixed in, everything in the last round"

```json
{
  "schemaVersion": 1,
  "id": "ai-boxing-10x3-mixups",
  "name": "Boxing 10x3 Mix-Ups",
  "rounds": [
    { "workSeconds": 180, "restSeconds": 60 },
    { "workSeconds": 180, "restSeconds": 60 },
    { "workSeconds": 180, "restSeconds": 60 },
    { "workSeconds": 180, "restSeconds": 60 },
    { "workSeconds": 180, "restSeconds": 60 },
    { "workSeconds": 180, "restSeconds": 60 },
    { "workSeconds": 180, "restSeconds": 60 },
    { "workSeconds": 180, "restSeconds": 60 },
    { "workSeconds": 180, "restSeconds": 60 },
    { "workSeconds": 180, "restSeconds": 60 }
  ],
  "cues": [
    { "kind": "random", "roundIndex": 0, "fromSec": 20, "toSec": 170, "commandPool": ["jab", "cross", "one-two", "body-shot"], "count": 10, "minGapSec": 8 },
    { "kind": "random", "roundIndex": 1, "fromSec": 20, "toSec": 170, "commandPool": ["jab", "cross", "one-two", "body-shot"], "count": 10, "minGapSec": 8 },
    { "kind": "random", "roundIndex": 2, "fromSec": 20, "toSec": 170, "commandPool": ["slip", "roll", "duck", "step-back"], "count": 10, "minGapSec": 8 },
    { "kind": "random", "roundIndex": 3, "fromSec": 20, "toSec": 170, "commandPool": ["jab", "cross", "hook", "uppercut"], "count": 10, "minGapSec": 8 },
    { "kind": "random", "roundIndex": 4, "fromSec": 20, "toSec": 170, "commandPool": ["slip", "roll", "pivot", "check-hook"], "count": 10, "minGapSec": 8 },
    { "kind": "random", "roundIndex": 5, "fromSec": 20, "toSec": 170, "commandPool": ["jab", "double-jab", "cross", "overhand"], "count": 10, "minGapSec": 8 },
    { "kind": "random", "roundIndex": 6, "fromSec": 20, "toSec": 170, "commandPool": ["slip", "duck", "body-shot", "hook"], "count": 10, "minGapSec": 8 },
    { "kind": "random", "roundIndex": 7, "fromSec": 20, "toSec": 170, "commandPool": ["jab", "cross", "one-two", "uppercut"], "count": 10, "minGapSec": 8 },
    { "kind": "random", "roundIndex": 8, "fromSec": 20, "toSec": 170, "commandPool": ["slip", "roll", "step-back", "pivot"], "count": 10, "minGapSec": 8 },
    { "kind": "random", "roundIndex": 9, "fromSec": 20, "toSec": 170, "commandPool": ["jab", "cross", "hook", "body-shot"], "count": 12, "minGapSec": 6 }
  ]
}
```

```text
FTR1.eyJzY2hlbWFWZXJzaW9uIjoxLCJpZCI6ImFpLWJveGluZy0xMHgzLW1peHVwcyIsIm5hbWUiOiJCb3hpbmcgMTB4MyBNaXgtVXBzIiwicm91bmRzIjpbeyJ3b3JrU2Vjb25kcyI6MTgwLCJyZXN0U2Vjb25kcyI6NjB9LHsid29ya1NlY29uZHMiOjE4MCwicmVzdFNlY29uZHMiOjYwfSx7IndvcmtTZWNvbmRzIjoxODAsInJlc3RTZWNvbmRzIjo2MH0seyJ3b3JrU2Vjb25kcyI6MTgwLCJyZXN0U2Vjb25kcyI6NjB9LHsid29ya1NlY29uZHMiOjE4MCwicmVzdFNlY29uZHMiOjYwfSx7IndvcmtTZWNvbmRzIjoxODAsInJlc3RTZWNvbmRzIjo2MH0seyJ3b3JrU2Vjb25kcyI6MTgwLCJyZXN0U2Vjb25kcyI6NjB9LHsid29ya1NlY29uZHMiOjE4MCwicmVzdFNlY29uZHMiOjYwfSx7IndvcmtTZWNvbmRzIjoxODAsInJlc3RTZWNvbmRzIjo2MH0seyJ3b3JrU2Vjb25kcyI6MTgwLCJyZXN0U2Vjb25kcyI6NjB9XSwiY3VlcyI6W3sia2luZCI6InJhbmRvbSIsInJvdW5kSW5kZXgiOjAsImZyb21TZWMiOjIwLCJ0b1NlYyI6MTcwLCJjb21tYW5kUG9vbCI6WyJqYWIiLCJjcm9zcyIsIm9uZS10d28iLCJib2R5LXNob3QiXSwiY291bnQiOjEwLCJtaW5HYXBTZWMiOjh9LHsia2luZCI6InJhbmRvbSIsInJvdW5kSW5kZXgiOjEsImZyb21TZWMiOjIwLCJ0b1NlYyI6MTcwLCJjb21tYW5kUG9vbCI6WyJqYWIiLCJjcm9zcyIsIm9uZS10d28iLCJib2R5LXNob3QiXSwiY291bnQiOjEwLCJtaW5HYXBTZWMiOjh9LHsia2luZCI6InJhbmRvbSIsInJvdW5kSW5kZXgiOjIsImZyb21TZWMiOjIwLCJ0b1NlYyI6MTcwLCJjb21tYW5kUG9vbCI6WyJzbGlwIiwicm9sbCIsImR1Y2siLCJzdGVwLWJhY2siXSwiY291bnQiOjEwLCJtaW5HYXBTZWMiOjh9LHsia2luZCI6InJhbmRvbSIsInJvdW5kSW5kZXgiOjMsImZyb21TZWMiOjIwLCJ0b1NlYyI6MTcwLCJjb21tYW5kUG9vbCI6WyJqYWIiLCJjcm9zcyIsImhvb2siLCJ1cHBlcmN1dCJdLCJjb3VudCI6MTAsIm1pbkdhcFNlYyI6OH0seyJraW5kIjoicmFuZG9tIiwicm91bmRJbmRleCI6NCwiZnJvbVNlYyI6MjAsInRvU2VjIjoxNzAsImNvbW1hbmRQb29sIjpbInNsaXAiLCJyb2xsIiwicGl2b3QiLCJjaGVjay1ob29rIl0sImNvdW50IjoxMCwibWluR2FwU2VjIjo4fSx7ImtpbmQiOiJyYW5kb20iLCJyb3VuZEluZGV4Ijo1LCJmcm9tU2VjIjoyMCwidG9TZWMiOjE3MCwiY29tbWFuZFBvb2wiOlsiamFiIiwiZG91YmxlLWphYiIsImNyb3NzIiwib3ZlcmhhbmQiXSwiY291bnQiOjEwLCJtaW5HYXBTZWMiOjh9LHsia2luZCI6InJhbmRvbSIsInJvdW5kSW5kZXgiOjYsImZyb21TZWMiOjIwLCJ0b1NlYyI6MTcwLCJjb21tYW5kUG9vbCI6WyJzbGlwIiwiZHVjayIsImJvZHktc2hvdCIsImhvb2siXSwiY291bnQiOjEwLCJtaW5HYXBTZWMiOjh9LHsia2luZCI6InJhbmRvbSIsInJvdW5kSW5kZXgiOjcsImZyb21TZWMiOjIwLCJ0b1NlYyI6MTcwLCJjb21tYW5kUG9vbCI6WyJqYWIiLCJjcm9zcyIsIm9uZS10d28iLCJ1cHBlcmN1dCJdLCJjb3VudCI6MTAsIm1pbkdhcFNlYyI6OH0seyJraW5kIjoicmFuZG9tIiwicm91bmRJbmRleCI6OCwiZnJvbVNlYyI6MjAsInRvU2VjIjoxNzAsImNvbW1hbmRQb29sIjpbInNsaXAiLCJyb2xsIiwic3RlcC1iYWNrIiwicGl2b3QiXSwiY291bnQiOjEwLCJtaW5HYXBTZWMiOjh9LHsia2luZCI6InJhbmRvbSIsInJvdW5kSW5kZXgiOjksImZyb21TZWMiOjIwLCJ0b1NlYyI6MTcwLCJjb21tYW5kUG9vbCI6WyJqYWIiLCJjcm9zcyIsImhvb2siLCJib2R5LXNob3QiXSwiY291bnQiOjEyLCJtaW5HYXBTZWMiOjZ9XX0
```

### 2 — "Wrestling takedown defense, 6 × 2:00 with 45s rest; hard sprawl calls in the last 30 seconds of every round, everything in the final round"

```json
{
  "schemaVersion": 1,
  "id": "ai-takedown-defense-6x2",
  "name": "Takedown Defense 6x2",
  "rounds": [
    { "workSeconds": 120, "restSeconds": 45 },
    { "workSeconds": 120, "restSeconds": 45 },
    { "workSeconds": 120, "restSeconds": 45 },
    { "workSeconds": 120, "restSeconds": 45 },
    { "workSeconds": 120, "restSeconds": 45 },
    { "workSeconds": 120, "restSeconds": 45 }
  ],
  "cues": [
    { "kind": "fixed", "roundIndex": 0, "atSec": 10, "commandId": "level-change" },
    { "kind": "random", "roundIndex": 0, "fromSec": 90, "toSec": 115, "commandPool": ["sprawl", "down-block"], "count": 4, "minGapSec": 5 },
    { "kind": "random", "roundIndex": 1, "fromSec": 90, "toSec": 115, "commandPool": ["sprawl", "down-block"], "count": 4, "minGapSec": 5 },
    { "kind": "random", "roundIndex": 2, "fromSec": 90, "toSec": 115, "commandPool": ["sprawl", "down-block", "circle"], "count": 4, "minGapSec": 5 },
    { "kind": "random", "roundIndex": 3, "fromSec": 90, "toSec": 115, "commandPool": ["sprawl", "down-block", "circle"], "count": 4, "minGapSec": 5 },
    { "kind": "random", "roundIndex": 4, "fromSec": 90, "toSec": 115, "commandPool": ["sprawl", "down-block", "snap-down"], "count": 4, "minGapSec": 5 },
    { "kind": "random", "roundIndex": 5, "fromSec": 60, "toSec": 115, "commandPool": ["sprawl", "down-block", "snap-down", "circle"], "count": 8, "minGapSec": 5 }
  ]
}
```

```text
FTR1.eyJzY2hlbWFWZXJzaW9uIjoxLCJpZCI6ImFpLXRha2Vkb3duLWRlZmVuc2UtNngyIiwibmFtZSI6IlRha2Vkb3duIERlZmVuc2UgNngyIiwicm91bmRzIjpbeyJ3b3JrU2Vjb25kcyI6MTIwLCJyZXN0U2Vjb25kcyI6NDV9LHsid29ya1NlY29uZHMiOjEyMCwicmVzdFNlY29uZHMiOjQ1fSx7IndvcmtTZWNvbmRzIjoxMjAsInJlc3RTZWNvbmRzIjo0NX0seyJ3b3JrU2Vjb25kcyI6MTIwLCJyZXN0U2Vjb25kcyI6NDV9LHsid29ya1NlY29uZHMiOjEyMCwicmVzdFNlY29uZHMiOjQ1fSx7IndvcmtTZWNvbmRzIjoxMjAsInJlc3RTZWNvbmRzIjo0NX1dLCJjdWVzIjpbeyJraW5kIjoiZml4ZWQiLCJyb3VuZEluZGV4IjowLCJhdFNlYyI6MTAsImNvbW1hbmRJZCI6ImxldmVsLWNoYW5nZSJ9LHsia2luZCI6InJhbmRvbSIsInJvdW5kSW5kZXgiOjAsImZyb21TZWMiOjkwLCJ0b1NlYyI6MTE1LCJjb21tYW5kUG9vbCI6WyJzcHJhd2wiLCJkb3duLWJsb2NrIl0sImNvdW50Ijo0LCJtaW5HYXBTZWMiOjV9LHsia2luZCI6InJhbmRvbSIsInJvdW5kSW5kZXgiOjEsImZyb21TZWMiOjkwLCJ0b1NlYyI6MTE1LCJjb21tYW5kUG9vbCI6WyJzcHJhd2wiLCJkb3duLWJsb2NrIl0sImNvdW50Ijo0LCJtaW5HYXBTZWMiOjV9LHsia2luZCI6InJhbmRvbSIsInJvdW5kSW5kZXgiOjIsImZyb21TZWMiOjkwLCJ0b1NlYyI6MTE1LCJjb21tYW5kUG9vbCI6WyJzcHJhd2wiLCJkb3duLWJsb2NrIiwiY2lyY2xlIl0sImNvdW50Ijo0LCJtaW5HYXBTZWMiOjV9LHsia2luZCI6InJhbmRvbSIsInJvdW5kSW5kZXgiOjMsImZyb21TZWMiOjkwLCJ0b1NlYyI6MTE1LCJjb21tYW5kUG9vbCI6WyJzcHJhd2wiLCJkb3duLWJsb2NrIiwiY2lyY2xlIl0sImNvdW50Ijo0LCJtaW5HYXBTZWMiOjV9LHsia2luZCI6InJhbmRvbSIsInJvdW5kSW5kZXgiOjQsImZyb21TZWMiOjkwLCJ0b1NlYyI6MTE1LCJjb21tYW5kUG9vbCI6WyJzcHJhd2wiLCJkb3duLWJsb2NrIiwic25hcC1kb3duIl0sImNvdW50Ijo0LCJtaW5HYXBTZWMiOjV9LHsia2luZCI6InJhbmRvbSIsInJvdW5kSW5kZXgiOjUsImZyb21TZWMiOjYwLCJ0b1NlYyI6MTE1LCJjb21tYW5kUG9vbCI6WyJzcHJhd2wiLCJkb3duLWJsb2NrIiwic25hcC1kb3duIiwiY2lyY2xlIl0sImNvdW50Ijo4LCJtaW5HYXBTZWMiOjV9XX0
```

### 3 — "MMA championship rounds, 5 × 5:00; open each fight with the jab, kick callouts late in every round"

```json
{
  "schemaVersion": 1,
  "id": "ai-mma-5x5-kicks-late",
  "name": "MMA 5x5, Kicks Late",
  "rounds": [
    { "workSeconds": 300, "restSeconds": 60 },
    { "workSeconds": 300, "restSeconds": 60 },
    { "workSeconds": 300, "restSeconds": 60 },
    { "workSeconds": 300, "restSeconds": 60 },
    { "workSeconds": 300, "restSeconds": 60 }
  ],
  "cues": [
    { "kind": "fixed", "roundIndex": 0, "atSec": 15, "commandId": "jab" },
    { "kind": "random", "roundIndex": 0, "fromSec": 240, "toSec": 295, "commandPool": ["low-kick", "body-kick", "head-kick", "teep"], "count": 6, "minGapSec": 7 },
    { "kind": "random", "roundIndex": 1, "fromSec": 240, "toSec": 295, "commandPool": ["low-kick", "body-kick", "head-kick", "teep"], "count": 6, "minGapSec": 7 },
    { "kind": "random", "roundIndex": 2, "fromSec": 240, "toSec": 295, "commandPool": ["low-kick", "body-kick", "knee", "check"], "count": 6, "minGapSec": 7 },
    { "kind": "random", "roundIndex": 3, "fromSec": 240, "toSec": 295, "commandPool": ["low-kick", "head-kick", "knee", "check"], "count": 6, "minGapSec": 7 },
    { "kind": "random", "roundIndex": 4, "fromSec": 180, "toSec": 295, "commandPool": ["low-kick", "body-kick", "head-kick", "teep", "knee"], "count": 10, "minGapSec": 8 }
  ]
}
```

```text
FTR1.eyJzY2hlbWFWZXJzaW9uIjoxLCJpZCI6ImFpLW1tYS01eDUta2lja3MtbGF0ZSIsIm5hbWUiOiJNTUEgNXg1LCBLaWNrcyBMYXRlIiwicm91bmRzIjpbeyJ3b3JrU2Vjb25kcyI6MzAwLCJyZXN0U2Vjb25kcyI6NjB9LHsid29ya1NlY29uZHMiOjMwMCwicmVzdFNlY29uZHMiOjYwfSx7IndvcmtTZWNvbmRzIjozMDAsInJlc3RTZWNvbmRzIjo2MH0seyJ3b3JrU2Vjb25kcyI6MzAwLCJyZXN0U2Vjb25kcyI6NjB9LHsid29ya1NlY29uZHMiOjMwMCwicmVzdFNlY29uZHMiOjYwfV0sImN1ZXMiOlt7ImtpbmQiOiJmaXhlZCIsInJvdW5kSW5kZXgiOjAsImF0U2VjIjoxNSwiY29tbWFuZElkIjoiamFiIn0seyJraW5kIjoicmFuZG9tIiwicm91bmRJbmRleCI6MCwiZnJvbVNlYyI6MjQwLCJ0b1NlYyI6Mjk1LCJjb21tYW5kUG9vbCI6WyJsb3cta2ljayIsImJvZHkta2ljayIsImhlYWQta2ljayIsInRlZXAiXSwiY291bnQiOjYsIm1pbkdhcFNlYyI6N30seyJraW5kIjoicmFuZG9tIiwicm91bmRJbmRleCI6MSwiZnJvbVNlYyI6MjQwLCJ0b1NlYyI6Mjk1LCJjb21tYW5kUG9vbCI6WyJsb3cta2ljayIsImJvZHkta2ljayIsImhlYWQta2ljayIsInRlZXAiXSwiY291bnQiOjYsIm1pbkdhcFNlYyI6N30seyJraW5kIjoicmFuZG9tIiwicm91bmRJbmRleCI6MiwiZnJvbVNlYyI6MjQwLCJ0b1NlYyI6Mjk1LCJjb21tYW5kUG9vbCI6WyJsb3cta2ljayIsImJvZHkta2ljayIsImtuZWUiLCJjaGVjayJdLCJjb3VudCI6NiwibWluR2FwU2VjIjo3fSx7ImtpbmQiOiJyYW5kb20iLCJyb3VuZEluZGV4IjozLCJmcm9tU2VjIjoyNDAsInRvU2VjIjoyOTUsImNvbW1hbmRQb29sIjpbImxvdy1raWNrIiwiaGVhZC1raWNrIiwia25lZSIsImNoZWNrIl0sImNvdW50Ijo2LCJtaW5HYXBTZWMiOjd9LHsia2luZCI6InJhbmRvbSIsInJvdW5kSW5kZXgiOjQsImZyb21TZWMiOjE4MCwidG9TZWMiOjI5NSwiY29tbWFuZFBvb2wiOlsibG93LWtpY2siLCJib2R5LWtpY2siLCJoZWFkLWtpY2siLCJ0ZWVwIiwia25lZSJdLCJjb3VudCI6MTAsIm1pbkdhcFNlYyI6OH1dfQ
```

## Self-check before you answer

Verify every line; a routine that fails any hard rule is rejected whole.

- [ ] `schemaVersion: 1`; non-empty string `id` (`ai-<slug>`) and `name` (≤ 40 chars).
- [ ] ≥ 1 round; every `workSeconds > 0`, every `restSeconds >= 0`.
- [ ] Every cue's `roundIndex` is 0-based and points at a round that exists.
- [ ] Every cue time sits inside `[1, workSeconds − 1]` of its round.
- [ ] Random zones: `fromSec < toSec`, `count >= 1` (integer), `minGapSec >= 0`, `commandPool` non-empty.
- [ ] `count ≤ (toSec − fromSec) / minGapSec + 1` for each zone.
- [ ] Every command id appears **verbatim** in the catalog above.
- [ ] No two cues in the same round within 1.5 s of each other (fixed cues especially).
- [ ] If emitting a code: it matches `^FTR1\.[A-Za-z0-9_-]+$` and was computed by executed code, not by hand.

## Designing a drill worth doing

- Match the sport's rhythm: boxing 3:00/1:00, MMA 5:00/1:00, wrestling often 2:00–3:00 with shorter rest.
- 6–12 calls per 3-minute round is a workable density; past ~15 the fighter is reacting to noise. Use `minGapSec` of 5–10 s for reaction drills.
- Vary pools round to round (hands early, defense middle, everything late) instead of one giant pool everywhere.
- A `fixed` cue at the start of round 1 (e.g. `level-change` at 0:10) sets the tone; random zones late in a round train tired-state reactions.
- Rest seconds of 0 are legal — use them for back-to-back stations.
