# TinyHold — rozhraní pro boty (Bot API) v1

**Datum:** 2026-09-26, doplnění 1.1 2026-09-28 (§13), 1.2 2026-10-09 (§14) · **Stav:** platí od verze 1.4 · **Protokol:** 1
**Pro hráče:** https://tinyhold.cz/Boti (návod a nejmenší bot)

Obecné rozhraní přes WebSocket na vašem počítači: bot dostává pravidla a stav hry a posílá
běžné herní rozkazy. Co si nad tím postavíte, je na vás. Hra přitom běží v normálním okně,
takže partii vidíte celou.

## 1. Rozsah

**v1 (tenhle dokument):** hra v prohlížeči se sama připojí k vašemu botovi.

**v2 (později, stejné zprávy):** headless aréna v Node pro rychlé série zápasů bez okna.

**Mimo v1:** vstup do místnosti přes API (do místnosti se jde jako dnes, klikáním), reconnect
s dohráním zmeškaných zpráv (po novém připojení přijde celý stav), pozorovatel, delty stavu.

## 2. Připojení

### 2.1 Role a adresa

Stránka v prohlížeči neumí poslouchat na portu, proto **bot je WebSocket server a hra se
k němu připojí**. Zapíná se parametry v adrese hry:

```text
https://tinyhold.cz/hrat/?bot=ws://127.0.0.1:7777&botklic=<tajný řetězec>
http://localhost:5173/hrat/?bot=ws://localhost:7777&botklic=<tajný řetězec>
```

- `bot` — adresa vašeho serveru. **Povolený je jen `ws://127.0.0.1:<port>` nebo
  `ws://localhost:<port>`**; jinou adresu hra odmítne (hláška v okně) a nepřipojí se.
- `botklic` — libovolný řetězec 16–128 znaků `[A-Za-z0-9_-]`, který si vygeneruje bot. Hra ho
  pošle v první zprávě (`hello.key`); **bot musí spojení s jiným klíčem zavřít**. Chrání vás
  před cizí webovou stránkou, která by se k vašemu portu připojila z prohlížeče.
- Doporučení pro bota: kontrolovat hlavičku `Origin` (`https://tinyhold.cz`,
  `http://localhost:<port>`).

**Prohlížeč se může zeptat na povolení** přístupu k zařízením v místní síti / aplikacím na
tomto počítači (Chrome „Local network access"). Pro `tinyhold.cz` je potřeba ho povolit.

### 2.2 Životní cyklus spojení

- Hra se připojí hned po načtení stránky a spojení drží přes všechny partie, dokud je stránka
  otevřená. Nejde-li se připojit nebo spojení spadne, **zkouší to znovu každé 2 s**; hra mezitím
  běží dál bez bota.
- Po každém (i opakovaném) připojení pošle `hello`; běží-li zrovna partie, hned za ním
  `gameStarted` s aktuálním stavem.
- V levém dolním rohu okna je vidět štítek **„Bot: připojen" / „Bot: nepřipojen"**.

## 3. Kde API funguje

| Situace | Bot dostává stav a smí posílat rozkazy |
|---|---|
| Hra proti počítači 1v1 i 2v2 | ano — bot hraje za slot hráče u okna |
| Online místnost 1v1 nebo 2v2 **bez žebříčku** | ano — bot hraje za slot hráče u okna |
| Online místnost **o žebříček** a zápas **turnaje** | **ne** — přijde `warning` `ranked`, rozkazy se odmítají (`ranked`) |

- **Pohled hráčského slotu (mlha):** bot dostává informace dostupné hráči přiděleného slotu.
  V režimu bez omezení viditelnosti může jít o celý stav hry. Pokud je viditelnost autoritativně
  omezena serverem, API nesmí zpřístupnit skryté informace, které nemá k dispozici ani hráč.
  - **Dnes** mlha autoritativní není, takže bot dostává **celý stav** (všechny entity všech
    hráčů) a okno se zapnutým API ukazuje **celou mapu**.
  - **V budoucnu** může server viditelnost omezit. Snímek pak může dostat třeba vrstvu
    `visibility` (po dlaždicích `'unseen' | 'remembered' | 'visible'`) a entity i mapa se zúží
    na to, co slot vidí nebo si pamatuje. Přesný formát ve v1 uzavřený není — **nepočítej
    napevno s tím, že dostaneš celý skrytý stav hry.**
  - V místnosti bez žebříčku o tom (bot i okno dnes vidí celou mapu) **ostatní hráči
    v místnosti nejsou nijak upozorněni** — hraj s botem jen s lidmi, kteří o tom vědí.
- **Člověk u okna může dál hrát myší.** Rozkazy od člověka i od bota jdou stejnou cestou;
  na jednotce platí ten poslední.
- **Návrat do rozehrané online partie** (obnovení stránky) API vypne, i když je místnost bez
  žebříčku — po obnovení stránka ještě nezná aktuální stav místnosti (ten přijde až příští
  `RoomState`), takže se bezpečně bere jako hodnocená.

## 4. Čas

- Simulace běží po **ticích**: 1 tick = **33 ms** herního času (~30 ticků za vteřinu).
- **Turn = 4 ticky** (~132 ms). Online se rozkazy vyměňují po turnech; offline turny nejsou,
  ale stav se posílá ve stejném rytmu.
- **Stav:** hra pošle `turn` pokaždé, když odsimuluje tick dělitelný 4 (`tick % 4 === 0`), tedy
  stav **na začátku** turnu `turn = tick / 4`. Bot si může rytmus zředit zprávou `config`
  (každý N-tý turn). Online může jeden rámec odsimulovat víc turnů najednou (dohánění po
  výpadku) — pak přijde jen stav toho posledního; stejně jako u vynechaných turnů dostává bot
  vždy nejnovější stav.
- **Na bota se nečeká.** Hra běží v reálném čase; co bot pošle, platí od nejbližšího možného
  ticku:
  - **offline:** od **následujícího odsimulovaného ticku** (`acceptedAtTick` v odpovědi);
  - **online:** rozkaz odejde v nejbližším balíčku lockstepu a platí **nejdřív za 2 turny**
    (8 ticků), u obou hráčů současně.
- **Zahlcení:** má-li hra v odchozí frontě víc než 2 MB (bot nestíhá číst), snímek `turn`
  vynechá; až se fronta uvolní, pošle `warning` `skipped-turns` s počtem vynechaných.

### 4.1 Velikost snímku

Plný `snapshot` (§6.2) roste s počtem entit na mapě — naměřeno na plné hrací ploše:

| Mapa | Entit | Snímek (JSON) |
|---|---|---|
| poc-64x64 | 601 | ≈ 103 kB |
| random-open-96 | 1506 | ≈ 261 kB |
| random-forest-64 | 2822 | ≈ 489 kB |
| random-forest-96 | 5910 | ≈ 1 MB |

Na lesních mapách tvoří stromy asi 90 % entit ve snímku. Na velkých mapách se proto hodí
zředit rytmus stavu zprávou `config` (`snapshotEveryTurns`, §5.2) — posílat delty místo
plných snímků je možná až v některé z příštích verzí protokolu.

## 5. Zprávy

Všechny zprávy jsou textové rámce WebSocketu, každý jeden objekt JSON s polem `type`.
Zprávy od hry nesou `seq` (od 1, roste o 1, po novém připojení začíná znovu od 1).
Neznámé pole ve zprávě se ignoruje; neznámý `type` od bota vrátí `error`.
**Neznámý `type` zprávy od hry bot ignoruje** — protokol se rozšiřuje přidáváním zpráv a polí
beze změny čísla `protocol` (tak přibylo doplnění 1.1, §13).

### 5.1 Hra → bot

```ts
type Hello = {
  type: 'hello'; seq: number; protocol: 1
  key: string                                   // hodnota ?botklic=
  game: { version: string; commit: string }     // např. "1.4.0", "e49bd12"
  rules: RulesCatalog                           // §9
  rulesHash: string                             // otisk katalogu, 8 hex znaků
}

type GameStarted = {
  type: 'gameStarted'; seq: number
  gameId: string                                // jedinečné v rámci stránky
  mode: 'offline' | 'online'
  seed: number; mapId: string; players: 2 | 4
  you: { slot: number; team: number }
  slots: { slot: number; team: number; controller: 'you' | 'human' | 'ai'; difficulty?: string }[]
  map: MapInfo                                  // §6.3
  snapshot: Snapshot                            // stav v okamžiku zprávy
}

type Turn = { type: 'turn'; seq: number; gameId: string; turn: number; tick: number; snapshot: Snapshot }

type NavigationGrid = {                         // 1.1: aktuální mřížka po změně (§6.3)
  type: 'navigationGrid'; seq: number; gameId: string; tick: number
  revision: number; width: number; height: number
  passable: string; buildable: string           // stejný base64 formát jako MapInfo
}

type CommandResult = {
  type: 'commandResult'; seq: number; requestId: string
  acceptedAtTick: number                        // tick hry v okamžiku přijetí; -1, když žádná partie neběží
  applied: number[]                             // indexy rozkazů předaných hře
  errors: { index: number; code: CommandError; message: string }[]
}

type QueryBuildResult = {
  type: 'queryBuildResult'; seq: number; requestId: string
  ok: boolean
  problem?: 'terrain' | 'unit' | 'building' | 'mine' | 'out-of-map'
}

type GameEnded = {
  type: 'gameEnded'; seq: number; gameId: string; tick: number
  result: 'decided' | 'draw' | 'abandoned' | 'network'
  winnerTeam: number | null                     // jen u 'decided'
  detail?: string                               // u 'network' důvod ze serveru (disconnected, desync…)
}

type Warning = {
  type: 'warning'; seq: number; code: 'ranked' | 'skipped-turns' | 'rate-limit'
  message: string; count?: number; requestId?: string          // requestId jen u rate-limit
}

type ErrorMsg = {
  type: 'error'; seq: number
  code: 'bad-json' | 'unknown-type' | 'too-large' | 'no-game' | 'stale-game' | 'ranked'
  message: string; requestId?: string
}
```

- `navigationGrid` přijde, když se `passable` nebo `buildable` liší od naposledy poslané podoby
  (stavba, zbořená budova, pokácený strom…) — nejvýš jednou za tick, **těsně před snímkem
  `turn` téhož ticku** (má o 1 nižší `seq` a stejný `tick`), takže mřížka vždy sedí se snímkem,
  který po ní čteš. Chodí jen spolu se snímkem: vynechaný snímek (`config`, zahlcení) vynechá
  i ji a změna odejde s nejbližším posílaným. `revision` roste o 1 s každou poslanou změnou
  (§6.3).
- `result`: `decided` = partie rozhodnutá (i vzdáním), `draw` = oba týmy přišly o poslední
  budovu v témž ticku, `abandoned` = hráč dal Restart nebo Konec do lobby uprostřed partie,
  `network` = online partie skončila bez rozhodnutí (odpojení, desync…). **Zavření karty
  žádný `gameEnded` nepošle** — spojení s botem se prostě zavře (`disconnected` ve štítku),
  stejně jako by spadl bot sám.
- `error` `bad-json` = zpráva není JSON, chybí jí `type`, nebo (u `commands`/`queryBuild`)
  `requestId`; `queryBuild` i `config` vrátí `bad-json` i pro špatný tvar svých vlastních polí
  (`what`/`at`, `snapshotEveryTurns`) — `ErrorMsg` samostatný kód pro „špatný tvar" nemá, ten
  má jen `CommandError` u jednotlivých rozkazů (§7.1). `stale-game`/`ranked`/`no-game`
  u `queryBuild` mají stejný význam jako u rozkazů (§7.1).
- `warning` `rate-limit` nese `requestId` zahozené zprávy `commands` — bot podle něj pozná,
  která z jeho zpráv se ztratila.

### 5.2 Bot → hra

```ts
type Commands = {
  type: 'commands'
  requestId: string                             // 1–64 znaků, jedinečné; opakované se neprovede
  gameId: string                                // z gameStarted; jiné → všechny rozkazy 'stale-game'
  commands: Command[]                           // 0–100 rozkazů, §7
}

type QueryBuild = {
  type: 'queryBuild'; requestId: string; gameId: string
  what: BuildingType; at: { tx: number; ty: number }   // levý horní roh půdorysu
}

type Config = { type: 'config'; snapshotEveryTurns: number }   // 1–30, výchozí 1
```

**Limity:** zpráva nejvýš **256 kB**; nejvýš **20 zpráv `commands` za vteřinu** (nad limit
přijde `warning` `rate-limit` a zpráva se zahodí); **online** navíc nejvýš **~8 kB rozkazů
(JSON) na jeden turn** — co se nevejde, pošli v dalším turnu (celá zpráva nad rozpočet dostane
`commandResult` s kódem `turn-budget`, §7.1); **offline** tenhle limit neplatí. V odpovědi
`commandResult` je vždy `requestId` z dotazu.

## 6. Stav

### 6.1 Souřadnice

- **Dlaždice** `{ tx, ty }`: celá čísla, `0 ≤ tx < width`, `0 ≤ ty < height`, počátek vlevo nahoře.
- **Světové souřadnice** `{ x, y }`: celá čísla v 1/256 dlaždice (`FIXED_ONE = 256`);
  dlaždice `tx` pokrývá `x` od `tx * 256` do `tx * 256 + 255`. Střed dlaždice = `tx * 256 + 128`.
- Rychlosti a dosahy v katalogu jsou ve stejných jednotkách (dosah 4 dlaždice = 1024;
  rychlost 0,085 dlaždice za tick = 22, po zaokrouhlení `toFixed`).

### 6.2 Snapshot

```ts
type Snapshot = {
  tick: number
  turn: number                                  // floor(tick / 4)
  players: {
    slot: number; team: number
    gold: number; wood: number
    pop: number; popCap: number
    alive: boolean                              // má aspoň jednu budovu (i rozestavěnou)
  }[]
  entities: EntitySnap[]                        // entity dostupné slotu (dnes všechny, §3), vzestupně podle id
}

type EntitySnap = {
  id: number                                    // pro rozkazy; po zániku se RECYKLUJE
  uid: number                                   // identita po celý život entity, nikdy se neopakuje
  kind: 'unit' | 'building' | 'resource'
  type: UnitType | BuildingType | 'goldmine' | 'tree'
  owner: number                                 // slot; -1 = nikdo (suroviny)
  pos: { x: number; y: number }                 // střed (světové souřadnice)
  tile: { tx: number; ty: number }              // dlaždice pod středem

  // jednotky a budovy
  hp?: number; maxHp?: number

  // jednotky
  order?: OrderSnap
  carrying?: { gold: number; wood: number }     // jen když něco nese
  hidden?: true                                 // dělník uvnitř dolu (nejde na něj útočit)
  cooldown?: number                             // ticků do dalšího úderu / léčení (0 = může; lučištník navíc potřebuje plný nátah, §14)
  mana?: number                                 // mnich
  movement?: {                                  // 1.1: jen když jednotka právě jde (u kteréhokoli rozkazu)
    destination: { tx: number; ty: number }     // kam jde PRÁVĚ TAHLE jednotka
    stalledTicks: number                        // ticků bez pokroku (0 = postupuje)
  }

  // budovy a suroviny
  origin?: { tx: number; ty: number }           // levý horní roh půdorysu
  size?: { w: number; h: number }               // v dlaždicích

  // budovy
  built?: boolean                               // false = staveniště
  buildProgress?: number                        // postup stavby 0…buildTicks; u HOTOVÉ budovy (built: true)
                                                 // je to jinak nepoužité pole a znamená rozpracovanou opravu
  production?: {
    queue: UnitType[]                           // [0] se právě cvičí
    progress: number                            // odpracované ticky položky [0]
    rally: { tx: number; ty: number } | null
  }

  // suroviny
  amount?: number                               // zbývá zlata / dřeva
}

type OrderSnap =
  | { kind: 'idle' }
  | { kind: 'move'; to: { tx: number; ty: number } | null }
  | { kind: 'gather'; target: number; phase: 'to-resource' | 'harvest' | 'to-dropoff'; resource: 'goldmine' | 'tree' }
  | { kind: 'build'; target: number }           // staveniště nebo oprava
  | { kind: 'return'; target: number | null }   // 1.1: nese náklad k budově (null = k nejbližší), §7
  | { kind: 'attack'; target: number | null; to: { tx: number; ty: number } | null; sweep: boolean }
  | { kind: 'hold' }
  | { kind: 'heal'; target: number }
  | { kind: 'follow'; target: number }
```

- **Zánik a vznik:** entita, která ve snímku chybí, zanikla. Nový `uid` = nová entita, i když
  dostala `id` po zaniklé. Rozkaz se dává přes `id`, proto vždy podle posledního snímku.
- `attack` s `target: null` = útok na místo (attack-move), jednotka jde na `to` a bije, co potká.
- **Kam jednotka jde — `move.to` × `movement`:**
  - `order.move.to` je **společný cíl skupiny**: dlaždice, na kterou mířil rozkaz `move`, a pro
    kterou hra počítá cestu celé skupině. `null` = jednotka nejde. Potvrzuje, že hra rozkaz
    převzala (`to` = dlaždice z tvého rozkazu), ale **není to místo, kam dojde tahle jednotka**
    — skupina se u cíle rozestoupí, každá na svou dlaždici, a ta může být o kus vedle.
  - `movement.destination` je **dlaždice, na kterou jde právě tahle jednotka** (její místo ve
    skupině). `movement` je ve snímku **právě tehdy, když jednotka jde** — u každého rozkazu
    (`move`, cesta k dolu, ke staveništi, k cíli útoku, `return`…, i u `idle`, když jde na
    shromaždiště nebo zpátky na stanoviště). Chybí = stojí. Výjimka: kopiník v zastavení po ráně (§14) má `movement` dál, ač stojí.
  - `movement.stalledTicks` = kolik ticků za sebou se jednotka k cíli nepřiblížila (0 =
    postupuje; u kopiníka v zastavení po ráně je počítadlo zmrazené, §14). Když by dosáhlo **400 ticků** (`STALL_TICKS`, ~13 s), jednotka chůzi **vzdá
    tam, kde stojí** — ve snímku tedy uvidíš nejvýš 399 a pak `movement` zmizí. Co dál, záleží
    na rozkazu:
    - `move` → `idle`, stojí tam, kde to vzdala;
    - `idle` s chůzí (shromaždiště, návrat na stanoviště) → stojí;
    - `gather`, `return`, `build` (i oprava), `heal` a `attack` na konkrétní cíl → rozkaz
      trvá, hra jednotku pošle znovu (nová chůze, `stalledTicks` od 0); po **3 marných
      pokusech** rozkaz skončí → `idle` (náklad zůstává v ruce);
    - `attack` na místo (`target: null`, attack-move) → jde-li přes mezicíl, pokračuje k `to`;
      jinak rozkaz skončí → `idle`;
    - `follow` → hra hned založí novou chůzi za vůdcem.

    Chůze skončí stejně i bez čekání, když cíl přestane být dosažitelný (žádná cesta, nebo na
    cílovou dlaždici někdo postavil budovu) — `movement` zmizí a platí tentýž seznam.

### 6.3 Mapa

```ts
type MapInfo = {
  revision: number                              // 1.1: revize mřížky, v každé partii od 0
  width: number; height: number
  passable: string                              // base64, 1 bajt na dlaždici (1 = průchozí), po řádcích: index = ty * width + tx
  buildable: string                             // totéž pro „smí se tu stavět"
  spawns: { slot: number; tile: { tx: number; ty: number } }[]
}
```

`passable`/`buildable` platí **v okamžiku `gameStarted`** (včetně startovních budov a
surovin). Hra mřížku za běhu mění (staveniště zabere půdorys, zbořená budova a pokácený strom
ho uvolní) — **změnu oproti naposledy poslané mřížce pošle zprávou `navigationGrid`** (§5.1)
s celou aktuální mřížkou a `revision` o 1 vyšší. Změny mezi dvěma posílanými snímky se slévají
do jedné revize (revize tedy nepočítá jednotlivé stavby, jen poslané podoby mřížky). Stačí si
držet poslední mřížku a nic nedopočítávat z entit.

- `revision` začíná v každé partii na `0`. Po (opětovném) připojení uprostřed partie nese
  `gameStarted` **aktuální** mřížku a **aktuální** revizi — změnila-li se mapa od poslední
  poslané podoby, je to nová revize. Tím je resynchronizace celá; dotaz typu
  `getNavigationGrid` proto protokol nemá.
- Zda se dá na konkrétní místo stavět teď (i s ohledem na jednotky a odstupy), řekne přesně
  `queryBuild`.

## 7. Rozkazy

Tytéž rozkazy jako z myši a panelu. `player` smí chybět (doplní se váš slot); je-li uveden,
musí se rovnat vašemu slotu.

```ts
type UnitType = 'pawn' | 'warrior' | 'spearman' | 'archer' | 'monk'
type BuildingType = 'castle' | 'barracks' | 'farm' | 'tower' | 'archery' | 'monastery'
type Tile = { tx: number; ty: number }
type World = { x: number; y: number }

type Command =
  | { kind: 'move'; units: number[]; to: World }
  | { kind: 'stop'; units: number[] }
  | { kind: 'hold'; units: number[] }
  | { kind: 'gather'; units: number[]; target: number }               // důl nebo strom
  | { kind: 'build'; units: number[]; what: BuildingType; at: Tile }  // at = levý horní roh půdorysu
  | { kind: 'repair'; units: number[]; target: number }               // vlastní poškozená hotová budova
  | { kind: 'attack'; units: number[]; target: number }
  | { kind: 'attack-move'; units: number[]; to: Tile; sweep: boolean }
  | { kind: 'heal'; units: number[]; target: number }                 // mnich → spojenecká jednotka
  | { kind: 'follow'; units: number[]; target: number }
  | { kind: 'train'; building: number; what: UnitType }
  | { kind: 'cancel-train'; building: number; index: number }         // index ve frontě
  | { kind: 'rally'; building: number; to: Tile }
  | { kind: 'rally-clear'; building: number }
  | { kind: 'surrender' }
  | { kind: 'return-resources'; units: number[]; target?: number }   // 1.1: odevzdat náklad
// (každý může mít navíc `player: number`)
```

- `sweep` u `attack-move`: `true` = cestou vyřizuje všechno v okruhu (jako hráčovo `A` + klik, s vodítkem, viz §14),
  `false` = jde k cíli a bije jen to, co mu zkříží cestu.
- `units` se deduplikuje (zachová se pořadí prvního výskytu) — poslat tutéž jednotku
  vícekrát nic nezmění, jen to zbytečně nafukuje velikost zprávy.
- `return-resources` (1.1) — **odevzdání nákladu**, regulérní rozkaz simulace (stejně offline
  i online, v záznamu i v přehrání na serveru):
  - týká se jen **vašich dělníků, kteří nesou zlato nebo dřevo** (`carrying` ve snímku); ostatní
    jednotky v `units` se přeskočí a zůstanou, jak byly;
  - `target` (nepovinný) = vaše **dokončená** budova, která suroviny přijímá
    (`acceptsResources` v katalogu — dnes hrad). Chybí (nebo `null`) → nejbližší taková budova,
    stejně jako při těžbě;
  - dělník dojde k budově stejnou cestou jako při těžbě, suroviny se připíšou **až při dojití**,
    pak **stojí** (`idle`) — k surovině se nevrací (na to je `gather`);
  - ve snímku má rozkaz `{ kind: 'return', target }` (`null` = k nejbližší);
  - není kam složit (žádná taková budova, je obestavěná nebo nedosažitelná — 3 marné pokusy)
    → `idle` a náklad zůstává v ruce; padne-li `target` cestou, jde k nejbližší jiné, jinak
    `idle` s nákladem.

### 7.1 Kontrola rozkazů

Hra kontroluje **tvar a vlastnictví**; rozkaz, který neprojde, se neprovede a vrátí se v
`errors` s indexem. Ostatní rozkazy téže zprávy platí. Chyba, která se týká **celé zprávy**, má
`index: -1` a `applied` je prázdné: `duplicate`, `stale-game`, `ranked`, `no-game`,
`turn-budget` (online, §5.2), a `bad-shape`, když `commands` chybí, není pole, nebo má přes
100 položek. `queryBuild` mimo běžící partii dostane zprávu `error` s kódem `no-game`
a svým `requestId`.

```ts
type CommandError =
  | 'unknown-kind'      // kind mimo seznam výš
  | 'bad-shape'         // rozkaz: chybí pole, špatný typ, necelé číslo, units není pole 1–200 čísel,
                        // neznámý what; CELÁ ZPRÁVA (index: -1): commands chybí/není pole/má přes 100 položek
  | 'foreign-slot'      // player ≠ váš slot
  | 'unknown-entity'    // id v units / building / target teď neexistuje
  | 'not-owner'         // units nebo building nejsou vaše; u return-resources i target, který není váš
  | 'not-carrying'      // 1.1, return-resources: nikdo z units nic nenese
  | 'bad-target'        // 1.1, return-resources: target je váš, ale náklad nepřijímá
                        //      (není to budova s acceptsResources, nebo je rozestavěná)
  | 'out-of-map'        // dlaždice nebo světová souřadnice mimo mapu
  | 'duplicate'         // requestId už přišlo (pamatuje se posledních 256) — týká se celé zprávy
  | 'stale-game'        // gameId není běžící partie — celá zpráva
  | 'ranked'            // hodnocený / turnajový zápas — celá zpráva
  | 'no-game'           // žádná partie neběží — celá zpráva
  | 'turn-budget'       // online: rozkazy by přesáhly rozpočet na tenhle turn — celá zpráva, zkus příští (§5.2)
```

**Pravidla hry kontroluje až simulace, stejně jako u člověka:** málo surovin, plná populace,
budova tu jednotku necvičí, nedá se tu stavět, jednotka na cíl nedosáhne… Takový rozkaz se
tiše neprovede a projeví se (neprojeví) v dalším snímku. Místo stavby ověří předem `queryBuild`.

## 8. Ověření stavby

`queryBuild` odpoví tou funkcí, kterou stavbu ověřuje simulace, nad stavem v okamžiku dotazu:

- `ok: true` — dá se tu stavět;
- `problem`: `terrain` (neprůchozí / nestavitelná dlaždice), `unit` (stojí tam jednotka),
  `building` (budova nebo odstup od ní), `mine` (moc blízko dolu), `out-of-map`.
- **Půdorys částečně mimo mapu:** `out-of-map` dostaneš, jen když je mimo mapu už `at` (levý
  horní roh půdorysu); přesahuje-li mimo mapu zbytek půdorysu (`at` samotné je v mapě), vrátí
  se `terrain` — dlaždice mimo mapu se pro stavbu počítá jako nestavitelná.

## 9. Katalog pravidel

Posílá se v `hello`. `rulesHash` = otisk kanonického JSON katalogu; změní-li se pravidla,
změní se i otisk.

```ts
type RulesCatalog = {
  protocol: 1
  time: { tickMs: 33; ticksPerTurn: 4; fixedOne: 256 }
  start: { gold: number; wood: number; pawns: number; popCap: number; popCapMax: number }
  units: Record<UnitType, {
    maxHp: number; speed: number; radius: number           // speed a radius ve světových jednotkách (za tick)
    cost: { gold: number; wood: number }; trainTicks: number; pop: 1
    armor: number; sight: number                            // sight v dlaždicích
    attack: { damage: number; spread: number; range: number; cooldown: number; aim: number; recovery: number } | null   // range ve světových jednotkách; aim = ticky nátahu (0 = bez); recovery = ticky zastavení po ráně (0 = bez)
    bonusDamage: Partial<Record<UnitType | BuildingType, number>>                        // přičte se před brněním
    heal: { amount: number; range: number; cooldown: number; manaCost: number; maxMana: number } | null
  }>
  manaRegenPerTick: number
  buildings: Record<BuildingType, {
    size: { w: number; h: number }; maxHp: number; buildTicks: number
    cost: { gold: number; wood: number }; repairCost: { gold: number; wood: number }
    popCap: number; trains: UnitType[]; acceptsResources: boolean
    armor: number; sight: number
    attack: { damage: number; spread: number; range: number; cooldown: number; aim: number; recovery: number } | null  // věž (aim 0, recovery 0)
  }>
  resources: Record<'goldmine' | 'tree', {
    size: { w: number; h: number }; cycleTicks: number; perCycle: number; carries: 'gold' | 'wood'; entersInside: boolean
  }>
  placement: { buildingClearance: number; mineClearance: number; maxBuilders: number }
  combat: { aggroRange: number; chaseMinGain: number; shunRange: number }   // světové jednotky
  commands: string[]                                        // povolené kind z §7
}
```

Hodnoty v době psaní, pro orientaci: bojovník 60 hp, 60 zl + 20 dř, 300 ticků, útok 12 ± 3,
dosah 0,8 dlaždice, brnění 2; lučištník 40 hp, 70 + 30, dosah 4, nátah 18 ticků (0,6 s), +10 proti bojovníkovi;
kopiník 45 hp, 60 + 20, rychlost 25, po ráně stojí 16 ticků (0,53 s), +6 proti lučištníkovi,
+30 proti mnichovi; mnich 35 hp, léčí 12 na 3 dlaždice; věž 600 hp,
300 zl + 250 dř, dosah 6. Strop populace 50, farma +5.

## 10. Záznam partie

- Rozkaz od bota se do záznamu partie uloží **v normalizované podobě** (doplněný `player`, bez
  neznámých polí — přesně to, co dostane simulace), jako kopie pořízená **v ticku, kdy ho
  simulace provede**, ne v okamžiku přijetí.
- Hra proti počítači hraná **se zapnutým Bot API** (`?bot=`) se odešle na server jako anonymní
  záznam s příznakem `bot: true` (odděleně od her lidí).
- Online partie se ukládají jako každý jiný zápas.

## 11. Bezpečnost

- Hra se připojí jen na `127.0.0.1` / `localhost` a pošle klíč z adresy; bot ověří klíč
  (a nejlépe i `Origin`).
- V hodnocených a turnajových zápasech API nepředává stav ani nepřijímá rozkazy.
- Klíč se nikam neloguje; limity velikosti a počtu zpráv viz §5.2.

## 12. Příklad

```json
← {"type":"hello","seq":1,"protocol":1,"key":"k3Y…","game":{"version":"1.4.0","commit":"e49bd12"},"rules":{…},"rulesHash":"9f2c01ab"}
← {"type":"gameStarted","seq":2,"gameId":"g1","mode":"offline","seed":3508275165,"mapId":"random-open-48","players":2,"you":{"slot":0,"team":0},"slots":[{"slot":0,"team":0,"controller":"you"},{"slot":1,"team":1,"controller":"ai","difficulty":"expert"}],"map":{"revision":0,…},"snapshot":{…}}
→ {"type":"config","snapshotEveryTurns":2}
← {"type":"turn","seq":3,"gameId":"g1","turn":2,"tick":8,"snapshot":{…}}
→ {"type":"commands","requestId":"r1","gameId":"g1","commands":[{"kind":"train","building":356,"what":"pawn"},{"kind":"gather","units":[360],"target":45}]}
← {"type":"commandResult","seq":4,"requestId":"r1","acceptedAtTick":9,"applied":[0,1],"errors":[]}
→ {"type":"queryBuild","requestId":"q1","gameId":"g1","what":"farm","at":{"tx":41,"ty":7}}
← {"type":"queryBuildResult","seq":5,"requestId":"q1","ok":true}
→ {"type":"commands","requestId":"r2","gameId":"g1","commands":[{"kind":"build","units":[361],"what":"farm","at":{"tx":41,"ty":7}}]}
← {"type":"commandResult","seq":6,"requestId":"r2","acceptedAtTick":10,"applied":[0],"errors":[]}
← {"type":"navigationGrid","seq":7,"gameId":"g1","tick":16,"revision":1,"width":48,"height":48,"passable":"AQEB…","buildable":"AQEB…"}
← {"type":"turn","seq":8,"gameId":"g1","turn":4,"tick":16,"snapshot":{…}}
…
→ {"type":"commands","requestId":"r7","gameId":"g1","commands":[{"kind":"return-resources","units":[360,362]},{"kind":"return-resources","units":[363]}]}
← {"type":"commandResult","seq":41,"requestId":"r7","acceptedAtTick":305,"applied":[0],"errors":[{"index":1,"code":"not-carrying","message":"Žádná z jednotek nic nenese."}]}
…
← {"type":"gameEnded","seq":812,"gameId":"g1","tick":8631,"result":"decided","winnerTeam":0}
```

## 13. Změny 1.1 (2026-09-28)

Doplnění podle požadavků autorů botů. Všechno je **přidané** — `protocol` zůstává `1` a bot
napsaný podle původní verze funguje dál (neznámé zprávy a pole ignoruje, §5).

- **Aktuální navigační mřížka:** nová zpráva `navigationGrid` (§5.1) po každé změně `passable`
  nebo `buildable`, těsně před snímkem téhož ticku; `MapInfo` má `revision` (§6.3). Po
  (znovu)připojení nese `gameStarted` aktuální mřížku a revizi, dotaz na mřížku není potřeba.
- **Pohled hráčského slotu:** §3 už nepočítá napevno s celým skrytým stavem — dnes je to celý
  stav, budoucí autoritativní mlha ho může omezit (formát `visibility` zatím otevřený).
- **Odevzdání nákladu:** nový rozkaz `return-resources` (§7), ve snímku rozkaz `return` (§6.2),
  nové kódy chyb `not-carrying` a `bad-target` (§7.1). Je i v katalogu (`rules.commands`) —
  proto se změnil `rulesHash`. Člověk ho vydá pravým klikem dělníkem s nákladem na vlastní
  nepoškozený hrad (na poškozený má přednost oprava — náklad zůstane v ruce).
- **Skutečný cíl chůze:** `EntitySnap.movement` (`destination`, `stalledTicks`) a přesný význam
  `move.to` včetně toho, co jednotka udělá po 400 tickách bez pokroku (§6.2).
- **Pravidlo pro rozšiřování:** neznámý `type` zprávy od hry bot ignoruje (§5).

## 14. Změny 1.2 (2026-10-09 až 2026-10-12)

Nátah luku, bonus kopiníka proti mnichovi a zastavení kopiníka po ráně — vyváženost kruhu
jednotek. Pole jen přibylo, `protocol` zůstává `1`.

- **Nátah:** lučištník musí po každém výstřelu znovu stát aspoň `attack.aim` ticků (bez vlastní
  chůze), než smí vystřelit znovu. Nátah se nabírá jen ve stoje; chůze ho nemaže (natažený luk
  vydrží cestu), výstřel ho vynuluje. Běží souběžně s nabíjením (`cooldown`), takže stojící
  lučištník střílí stejně rychle jako dřív. Odstrkování ve skupině ani přepnutí cíle nátah nenuluje. Nový lučištník začíná s nátahem 0 — i první rána potřebuje
  `attack.aim` ticků stání.
- **Katalog:** `attack.aim` u každé jednotky a budovy s útokem (0 = bez nátahu, dnes všechno
  kromě lučištníka). Proto se změnil `rulesHash`.
- **Snímek se nemění:** nátah si bot dopočítá sám (ticky stání od poslední rány).
  Snímek chodí po tazích (4 ticky), takže přesný tick nátahu bot jen odhadne. Tick, ve kterém chůze skončila,
  se už počítá jako stání; tick zastavení před ranou ne.
- **Kopiník:** bonus +30 proti mnichovi (`bonusDamage.monk`), složí ho jednou ranou. Léčení mnicha
  se nemění. Nová dvojice v `bonusDamage`, proto i nový `rulesHash`.
- **Zastavení kopiníka:** po každé ráně kopiník `attack.recovery` ticků (16) nejde vlastní chůzí.
  Rozkaz a cíl chůze zůstanou, vyrazí po uplynutí; odstrkování platí dál. Rychlost kopiníka je 25
  (dřív 24). Nové pole `attack.recovery` u každé jednotky a budovy s útokem (0 = bez zastavení),
  proto nový `rulesHash`. Snímek se nemění.
  - Během zastavení zůstává `movement` ve snímku (až 16 ticků), ač jednotka stojí.
    Počítadlo `stalledTicks` je v tu dobu zmrazené, takže 0 neznamená, že jednotka postupuje.
  - Zbývající zastavení si bot dopočítá: `recoveryLeft = max(0, cooldown − (attack.cooldown − attack.recovery))`.
    Platí, protože `cooldown` i zastavení nastaví tatáž rána a ubývají na stejném místě v `combat`.
    Chybí-li `cooldown` ve snímku, počítej s 0 (jednotka nenabíjí, zastavení je za ní).
    Stejně jako u `aim` jde o odhad po tazích (4 ticky).
- **Kopiník proti lučištníkovi:** `bonusDamage.archer` u kopiníka je 13 (dřív 6). Rána 20–24 složí
  lučištníka (40 HP) vždy dvěma ranami. Proto nový `rulesHash`. Snímek se nemění.
- **Útočné vlny počítače** míří cestou nejdřív na jednotky, pak na věže, pak na budovy (stejně jako
  attack-move hráče). Rozhraní bota se to netýká.
- **Vodítko útoku pohybem (2026-10-12):** `attack-move` se `sweep: true` přestane honit uprchlíka,
  kterého nedohání. Uprchlík je stejně rychlý nebo rychlejší a jednotka se k němu za honičku
  nepřiblížila aspoň o `combat.chaseMinGain`. Znovu ho vezme, až bude blíž než
  `max(attack.range, combat.shunRange)`. Nový rozkaz zákaz smaže. Platí stejně pro hráče, vlny počítače
  i bota. Nová pole v katalogu, proto nový `rulesHash`. Snímek se nemění.
