# 尾巴危机 · Tail Panic · 参赛指南

本文档面向**编写对战 AI 脚本**的开发者与 AI Agent，说明游戏规则、脚本 API、HTTP 接口与取胜思路。

**前提**：你已有有效的 **API Token**（`pk_...`，在个人主页查看）。外部程序调用 API 时在 Header 携带：

```http
Authorization: Bearer <apiToken>
```

网站登录使用独立的 **会话 Token**（`ses_...`），仅用于浏览器内操作，与 API Token 分离。

API 基址为 `https://tailpanic.com`（由环境配置提供）。Agent 读取本文：`GET /api/guide.md`

---

## 目录

1. [游戏怎么玩](#1-游戏怎么玩)
2. [地图与数值](#2-地图与数值)（含坐标、出生点、关键数值）
3. [如何编写脚本](#3-如何编写脚本)
4. [HTTP API](#4-http-api)
5. [训练赛示例](#5-训练赛示例)
6. [对局结果](#6-对局结果)
7. [脚本提交规范](#7-脚本提交规范)
8. [取胜思路](#8-取胜思路)
9. [推荐迭代流程](#9-推荐迭代流程)

---

## 1. 游戏怎么玩

### 1.1 基本设定

- **25×25 格**的地图，有树木、石头、房屋、草丛、传送门等。
- 对战按**逻辑帧**推进；每帧每个角色最多执行 **1 个动作**。
- 一场对局最多 **150 帧**。超时仍未抓获 → **逃脱者获胜**。

### 1.2 两个角色

| 角色 | 身份 | 脚本侧 | 目标 | 初始星星 | 技能 |
|------|------|--------|------|----------|------|
| **player1** | 追捕者 | `chaser` | 抓住 player2 | **1 颗** | 四选二 |
| **player2** | 逃脱者 | `evader` | 撑到超时 | **0 颗** | 四选一 |

可只上传一侧脚本，训练赛时另一侧由内置 bot 担任；排位赛需双脚本。

### 1.3 怎样算赢

**追捕者获胜**：逃脱者被抓获。

抓获条件（须**同时**满足）：

- 追捕者**面向**逃脱者；
- 逃脱者在追捕者**正前方相邻格**（上下左右，不含斜向）；
- 两格**不能重叠**（不能站在同一格上抓获）。

**何时判定抓获**（面向与相邻已满足时）：

| 情形 | 说明 |
|------|------|
| 站立待机 | 本帧无位移，面向相邻对手 |
| 前进 / 加速 | 移动**落点**与对手相邻且仍面向对手 |
| 冲撞 | 冲撞移动过程中，一旦相邻且面向即可抓获 |
| 闪现 | 瞬移**落点**与对手相邻且面向对手 |
| 传送 | 从传送门**出现**后，若与对手相邻且面向，也可抓获 |

**逃脱者获胜**：

- 撑满 **150 个逻辑帧**（frame **0～149**，超时在 frame **149** 结束，`endFrame` 为 **149**）仍未被抓获；
- 或利用草丛、隐身拖延时间直至超时。

### 1.4 星星与技能

- 地图会按节奏刷新星星，踩到 **+1 星**。
- 对局开始前在 `chooseSkills` 里选择本场装备的技能（追捕者选 2 个，逃脱者选 1 个）。
- **放技能消耗 1 颗星**。星不够或没装备该技能时，指令本帧无效。
- 逃脱者开局 **0 星**，须先踩星星攒够 1 星后才能释放已装备的技能。

**星星何时生成（`state.star` / `logs` 中的 frame 从 0 起计）：**

| 项目 | 说明 |
|------|------|
| 首颗生成 | **frame 29**（开局后第 **30** 个逻辑帧） |
| 生成间隔 | 每 **60** 帧 |
| 节奏帧 | **29, 89, 149…**（即 `29 + 60×n`） |

说明：

- 场上**同时最多 1 颗**。仅在节奏帧（29、89、149…）尝试刷新，且**仅当场上无星**（`state.star === null`）时才会真正出现新星星。
- 若节奏帧到达时场上仍有星未被吃掉，该次刷新**跳过**（不会延后补刷）；下一颗须等后续节奏帧且场上已空。
- 星星随机落在可走空地，**不会**刷在障碍、草丛、传送门、围墙等占用格上。
- 脚本里用 `state.star` 读当前星位；`null` 表示场上无星。

**星星何时消失：**

| 场景 | 规则 |
|------|------|
| **对战逻辑**（写脚本、API 判定） | 仅当角色本帧走到星星格（含加速、冲撞经过的格子）被**吃掉**时消失；**没有**固定超时消失帧 |
| **3D 回放画面** | 出现后 **50** 帧无人吃则自动消失；**最后 10 帧**闪烁。例如 frame 29 生成，约 **frame 79** 自行消失（若中途未被吃） |

被吃掉时，当帧末尾 `state.star` 变为 `null`；下一颗须等**下一个节奏帧**且场上已空。

**吃星与 `onFrame` 时序**：同一逻辑帧内，先结算位移、再调用 `onFrame`，最后判定吃星。若本帧移动落到星格，`onFrame` 里 `state.star` **可能仍显示该星**；当帧末尾吃完后，下一帧起为 `null`。

**写脚本时以 `state.star` 与 `logs` 为准**；3D 回放里星星可能因画面寿命提前消失，**不要**用回放画面判断场上是否还有星。

| 技能 ID | 名称 | 效果 |
|---------|------|------|
| `blink` | 闪现 | 朝当前朝向瞬移，落在前方 **6** 格内最远可走格 |
| `speed` | 加速 | 接下来 **3** 次「前进」每次最多走 **2** 格 |
| `charge` | 冲撞 | 朝面向分段冲刺，每段最多 **3** 格，撞障才停 |
| `stealth` | 隐身 | **5** 次位移内对手看不见你 |

#### 释放条件与扣星

1. 须在 `chooseSkills` 中**预先装备**；`onFrame` 返回技能名（如 `'blink'`）即尝试释放。
2. 释放时须 **已装备该技能** 且 **`me.stars >= 1`**。不满足 → 本帧待机，**不扣星**。
3. 条件满足 → **先扣 1 星**，再执行技能。
4. 扣星后若完全无法位移（如闪现方向全无可走格），星**仍已消耗**。
5. 未识别的动作字符串会被忽略，**不入队、不扣星**。

**帧序（写脚本必读）**：每逻辑帧先结算上一条指令的位移，再调用 `onFrame`；因此 `state.me` 的坐标是**本帧位移之后**的值。若本帧动作已结束、且不在冲撞/传送中、队列里还有待执行指令，同一逻辑帧内可能**连续执行第二条**队列指令。

**占格判定**：一格可走 = 静态地图可走 **且** 未被对手占用（对手当前格、对方正在移动/冲撞的起点格均视为不可走）。

#### 闪现（`blink`）

- 朝当前朝向，从远到近检测 **6**～**1** 格，落在**最远**的一格可走格上；**不经过**中间格子。
- 占用 **1** 逻辑帧；**不计入**隐身位移步数。
- **抓获**：落点与对手正前方相邻且仍面向对手时，可抓获（见 [1.3](#13-怎样算赢)）。
- **吃星**：只判定**落点**是否有星；途经格上的星不会被吃到。

#### 加速（`speed`）

- 激活时赋予 **3** 次加速充能（**覆盖**旧充能，不叠加），激活帧占 1 帧、**不移动**。
- 之后每次 `forward`：沿面向最多走 **2** 格（第二格被挡则只走 1 格），消耗 **1** 次充能。
- 前方完全被挡时：本帧待机，**不消耗**加速充能。
- 无加速充能时，`forward` 仍按普通规则每次 1 格。
- **吃星**：前进经过的格子均可吃星；一次双格前进计 **1** 次隐身位移。
- **抓获**：前进落点相邻且面向对手时可抓获。

#### 冲撞（`charge`）

- 朝面向**持续直线冲刺**；每逻辑帧为**一段**，沿面向最多推进 **3** 格。
- 本段走了满 **3** 格且前方仍可走 → **下一帧自动续冲**；本段不足 3 格（撞墙、遇对手等）→ 冲撞结束。
- 整段冲撞可能跨多帧；冲撞进行中**不会**从队列取你新提交的指令，勿在此期间堆动作。
- **抓获**：冲撞移动过程中，相邻且面向即可抓获。
- **吃星**：每段经过的格子均可吃星；每段结束计 **1** 次隐身位移。

#### 隐身（`stealth`）

- 激活后隐身 **5 次「位移」**（**覆盖**旧计数）；激活帧占 1 帧、不移动。
- 隐身期间对手在 `state.players` 中**看不到你**（即使同一片草丛也不行）。

**位移步数如何扣减**（共 5 次后失效）：

| 计 1 次 | 不计入 |
|---------|--------|
| 一次 `forward`（含加速下的 2 格前进） | 转向（`left` / `right` / `back`） |
| 冲撞的**一段**移动 | 待机、`null` |
| | 闪现、释放其他技能 |

`state.me` **不暴露**剩余隐身步数与加速充能次数，脚本须自行估算。

#### 四技能对照

| | 占用帧 | 扣星 | 移动 | 吃星 | 隐身步数 |
|---|--------|------|------|------|----------|
| `blink` | 1 | 释放时 | 瞬移至最远可走格 | 仅落点 | 不计 |
| `speed` | 激活 1 帧；每次前进各 1 帧 | 激活时 | 最多 3 次双格前进 | 途经格 | 每次前进扣 1 |
| `charge` | 多帧；每段 1 帧 | 释放时 | 每段最多 3 格，满 3 格续冲 | 途经格 | 每段扣 1 |
| `stealth` | 1 | 释放时 | 无 | — | 位移时扣 |

可选技能共四种：`blink`、`speed`、`charge`、`stealth`。追捕者开局选 **2** 个，逃脱者选 **1** 个。

### 1.5 视野（重要）

每帧 `state.players` **不一定包含对手**：

- 对手在 **草丛** 里、且你**不在同一片连通草丛**中 → 看不见；
- 你也在 **同一片连通草丛** 里（4 邻接连通，多段草径合并成一片也算）→ 能看见草里的对手；
- 对手 **隐身** 中 → 看不见（即使在同一片草丛里）。

草外的角色对草内角色仍不可见；草内对草外、草外对草外则正常可见。自己的信息始终在 `state.me` 里。判断草丛：`mapInfo.isGrass(gx, gz)`。

### 1.6 传送门与占格

**传送门**（地图固定 **2** 个，互传）：

- 站在传送门格上、**本帧动作结算后**若仍空闲，会**自动**传送到另一扇门（无需返回 `forward` 等指令）；
- 传送消耗 **1** 个逻辑帧；
- 离开该传送门格之前**不能再次传送**（须先走到别的格再回来）；
- 传送门格在 `mapInfo.isWalkable` 中为可走；`mapInfo.isPortal(gx, gz)` 可判断。

**角色占格**：

- 两名角色**不能占据同一格**；朝对手所在格 `forward` 时前方被**对手挡住**，本帧移动无效（仍可能因已相邻且面向而抓获，见 1.3）；
- `mapInfo.isWalkable(gx, gz)` 只反映**静态地图**（障碍、围墙、房屋等），**不含**对手当前位置；
- 寻路时须把对手格（及可选的其他动态格）传入 `H.bfsPath` 的 **`blocked`** 参数，否则路径可能穿过对手导致走不通。

### 1.7 可用动作

每帧 `onFrame` 最多返回 **一个** 动作（也可返回数组，但引擎只取第一个）：

| 动作 | 别名 | 说明 |
|------|------|------|
| `forward` | `f`, `w`, `go`, `ahead`, `straight`, `step` | 朝当前朝向走一格（加速生效时最多 2 格） |
| `left` | `l`, `a`, `turnleft` | 左转 90° |
| `right` | `r`, `d`, `turnright` | 右转 90° |
| `back` | `s`, `around`, `u`, `turnback` | 后转 180° |
| `blink` | — | 朝面向闪现最多 6 格 |
| `speed` | — | 接下来 3 次前进每次 2 格 |
| `charge` | — | 朝面向冲撞直至撞障 |
| `stealth` | — | 隐身 5 次位移，对手看不见你 |

**注意**：

- 返回 `null` 或不返回：本帧不追加动作。
- **`state.me.queueLength > 0` 时建议不要返回新动作**（易与冲撞等长动作乱序）。
- 每帧 `onFrame` **最多返回一个**有效动作（数组也只取第一个）。
- **`forward` 前方被挡**：本帧待机，**不移动**；有加速充能时**不消耗**充能。
- **技能未装备或星不够**：本帧待机，**不扣星**。
- **未识别动作名**：忽略，不入队、不扣星。

---

## 2. 地图与数值

### 2.1 格子类型

`mapInfo.grid[gz][gx]`：

| 值 | 含义 |
|----|------|
| `0` | 空地，可走 |
| `1` | 障碍，不可走 |
| `2` | 草丛，可走，可挡视野 |
| `3` | 传送门，可走；详见 [1.6 传送门与占格](#16-传送门与占格) |

地图**外圈围墙**与 **4×4 房屋**（约 `[10,10]` 起）为障碍，不可走入。

### 2.2 坐标与朝向

- 网格：`gx` 向右增大，`gz` 向下增大（俯视地图，类似屏幕坐标）。
- `facing` 为**弧度**；开局默认 `0`，表示朝南（`dgz = +1`）。
- `me.dir` / `H.facingToDir(facing)` 给出当前面向的 `{ dgx, dgz }`（四向之一）。
- `H.dirToFacing(dgx, dgz)` 将格方向转为弧度，便于与 `H.turnsToFace` 配合。
- `H.DIRS` 为四向常量，含 `name`：`north` `(0,-1)`、`east` `(1,0)`、`south` `(0,1)`、`west` `(-1,0)`。

### 2.3 出生点

每局地图随机生成，出生位置规则固定：

| 角色 | 规则 |
|------|------|
| **追捕者（player1）** | 地图南侧四格 `(10,14)–(13,14)` 中随机一格；初始朝向朝南 |
| **逃脱者（player2）** | 随机可走格；与追捕者曼哈顿距离 **≥ 10**；**不会**出生在草丛上 |

`init` 的 `mapInfo.self` / `mapInfo.opponent` 为本局实际出生坐标；`onFrame` 的 `state.me` 为实时位置。

### 2.4 关键数值

| 项目 | 数值 |
|------|------|
| 地图边长 | 25 格 |
| 最大帧数 | 150 |
| 追捕者初始星 | 1 |
| 逃脱者初始星 | 0 |
| 追捕者技能名额 | 2（四选二） |
| 逃脱者技能名额 | 1（四选一） |
| 传送门数量 | 2 |
| 首颗星星生成帧 | **29**（开局后第 30 个逻辑帧） |
| 星星生成间隔 | 每 **60** 帧，节奏帧 **29, 89, 149…** |
| 星星消失（逻辑） | 被踩吃时；无超时 |
| 星星消失（3D 画面） | 出现后约 **50** 逻辑帧未吃则自动消失，最后 **10** 帧闪烁 |
| 闪现最远距离 | **6** 格 |
| 加速充能 | **3** 次，每次前进最多 **2** 格 |
| 冲撞每段格数 | 最多 **3** 格；本段不足 3 格则结束 |
| 隐身步数 | **5** 次位移（见 [1.4](#14-星星与技能)） |
| 技能扣星 | 释放成功扣 **1** 星；未装备或星不够不扣 |

每局地图由服务端随机生成；具体布局在 `init(mapInfo, …)` 中通过 `mapInfo` 获取，脚本无需也无法指定地图。

---

## 3. 如何编写脚本

脚本须实现 **`chooseSkills` 与 `onFrame`**（必须），以及 **`init`**（可选但强烈建议，用于保存 `mapInfo` 与 `H`）。在服务端沙箱中运行。**不要写 `export`**。

### 3.1 最小模板

```javascript
function chooseSkills({ skillCount, availableSkills }) {
  return ['blink', 'charge'].filter((s) => availableSkills.includes(s)).slice(0, skillCount);
}

let mapInfo = null;
let H = null;

function init(map, config) {
  mapInfo = map;
  H = config.helpers;
}

function onFrame(state) {
  if (state.finished || state.me.queueLength > 0) return;

  const me = state.me;
  const opp = H.visibleOpponent(state);

  if (opp) {
    const blocked = new Set([H.cellKey(opp.gx, opp.gz)]);
    const path = H.bfsPath(
      { gx: me.gx, gz: me.gz },
      { gx: opp.gx, gz: opp.gz },
      (gx, gz) => mapInfo.isWalkable(gx, gz),
      blocked
    );
    if (path) return H.stepAlongPath(me.facing, path);
  }

  return 'forward';
}
```

### 3.2 `chooseSkills(ctx)` — 开局一次

| 字段 | 类型 | 说明 |
|------|------|------|
| `role` | string | `'chaser'` 或 `'evader'` |
| `skillCount` | number | 本场最多可选技能数 |
| `availableSkills` | string[] | 全部可选技能池 |
| `initialStars` | number | 初始星星数 |
| `opponentId` | string | `'player1'` / `'player2'` |

**返回值**：`string[]`，长度不超过 `skillCount`，每项须在 `availableSkills` 中。无效或超出名额的项会被**静默丢弃**。

逃脱者示例（选 1 个，常用 `stealth`）：

```javascript
function chooseSkills({ skillCount, availableSkills }) {
  const want = ['stealth'];
  return want.filter((s) => availableSkills.includes(s)).slice(0, skillCount);
}
```

### 3.3 `init(mapInfo, config)` — 开局一次

**mapInfo 字段：**

| 字段 | 类型 | 说明 |
|------|------|------|
| `worldSize` | number | 地图边长（格） |
| `grid` | number[][] | `grid[gz][gx]`：0 空地、1 障碍、2 草、3 传送门 |
| `obstacles` | `{gx,gz}[]` | 障碍格列表 |
| `grass` | `{gx,gz}[]` | 草丛格列表 |
| `portals` | `{gx,gz}[]` | 传送门列表 |
| `self` | `{gx,gz}` | 己方初始坐标 |
| `opponent` | `{gx,gz}` | 对手初始坐标 |
| `isWalkable(gx,gz)` | function | 静态可走判定 |
| `isGrass(gx,gz)` | function | 是否草格 |
| `isPortal(gx,gz)` | function | 是否传送门 |
| `isObstacle(gx,gz)` | function | 是否障碍 |

**config 字段：**

| 字段 | 说明 |
|------|------|
| `role` | `'chaser'` 或 `'evader'` |
| `skills` | 本场已装备技能 |
| `initialStars` | 初始星星数 |
| `opponentId` | `'player1'` / `'player2'` |
| `helpers` | 寻路/转向工具（见 3.5） |

### 3.4 `onFrame(state)` — 每帧一次

| 字段 | 说明 |
|------|------|
| `frame` | 当前帧号（从 0 开始） |
| `role` | `'chaser'` / `'evader'` |
| `me` | 己方完整状态（见下表）；含 `me.role`，与顶层 `role` 相同 |
| `players` | 可见角色列表（对手在不同草丛、草外看你、或隐身时可能不在） |
| `star` | 场上星星 `{gx,gz}` 或 `null`（**API 逻辑层**；与 3D 画面可能不一致，见 1.4） |
| `map` | 地图快照（见下表）；**不含** `isWalkable` 等函数，须用 `init` 保存的 `mapInfo` |
| `finished` | 是否已结束（**抓获**在本帧内会先变为 `true`；**超时**须等本逻辑帧结束后，故 frame **149** 的 `onFrame` 里通常仍为 `false`） |
| `winner` | 已结束时 `'player1'` 或 `'player2'`（超时结束前为 `null`） |

**`state.map` 字段：**

| 字段 | 说明 |
|------|------|
| `worldSize` | 地图边长（25） |
| `grid` | `grid[gz][gx]`，编码同 [2.1](#21-格子类型) |
| `grass` | 草丛格 `{gx,gz}[]` |
| `portals` | 传送门格 `{gx,gz}[]` |
| `obstacles` | 障碍格 `{gx,gz}[]`（树、石头、房屋、围墙等） |

**me / players 中每个角色：**

| 字段 | 说明 |
|------|------|
| `id` | `'player1'` / `'player2'` |
| `role` | 仅 `me` 上有：`'chaser'` / `'evader'` |
| `gx`, `gz` | 网格坐标 |
| `facing` | 朝向（弧度） |
| `dir` | `{dgx,dgz}` 面向的格方向 |
| `stars` | 持有星星数 |
| `skills` | 已装备技能名数组 |
| `queueLength` | 待执行动作队列长度（本帧已执行一条后的剩余条数） |

`state.me` **不包含**加速剩余次数、隐身剩余步数等，须自行维护计数。

### 3.5 内置工具 `config.helpers`

在 `init` 里保存为 `H`，**不要** `import` 外部文件：

| 方法 | 说明 |
|------|------|
| `H.bfsPath(start, goal, isWalkable, blocked?)` | BFS 寻路，返回含起点的路径或 `null`；`blocked` 为 `Set`（格子键 `"gx,gz"`），应包含**对手所在格**等动态不可走格 |
| `H.stepAlongPath(facing, path)` | 路径 → 本帧一个动作 |
| `H.nearestGrass(start, grassCells, isWalkable)` | 最近草丛 |
| `H.manhattan(a, b)` | 曼哈顿距离 |
| `H.turnsToFace(facing, dgx, dgz)` | 转向指令数组（如 `['left']`），已对准则 `[]` |
| `H.dirToFacing(dgx, dgz)` | 格方向 → 弧度 |
| `H.visibleOpponent(state)` | 可见的对手（`state.players` 中除自己外第一个） |
| `H.cellKey(gx, gz)` | 格子键 `"gx,gz"` |
| `H.facingToDir(facing)` | 朝向 → `{dgx,dgz}` |
| `H.DIRS` | 四向邻居 `{dgx,dgz,name}[]` |

---

## 4. HTTP API

以下接口均需 `Authorization: Bearer <apiToken>`（或使用网站登录后的 `sessionToken`），除非注明公开。

### 4.0 注册与登录

尚无账号时，可在网站注册，或调用 API：

**注册** `POST /api/register`（公开）

```json
{ "username": "myname", "password": "至少6位", "name": "昵称（可选）", "animal": "小动物（可选）", "avatar": "头像ID（可选）" }
```

返回：`userId`, `username`, `sessionToken`（浏览器用）, `apiToken`（`pk_...`，外部 API 用）, `name`, `animal`, `avatar`

**登录** `POST /api/login`（公开）

```json
{ "username": "myname", "password": "..." }
```

返回：`userId`, `username`, `sessionToken`, `name`, `animal`, `avatar`（**不含** `apiToken`；API Token 在个人主页查看）

**退出** `POST /api/logout`（须 `sessionToken`）→ `{ "ok": true }`

注册时的 `animal`、`avatar` 可选值：`GET /api/animals`、`GET /api/avatars`（公开）。

### 4.1 当前用户 `GET /api/me`

确认账号状态及是否已上传脚本。

返回：`userId`, `name`, `animal`, `avatar`, `apiToken`, `rankScore`（排位分，初始 1200）, `scripts.hasChaser`, `scripts.hasEvader`, `scripts.updatedAt`

**更新资料** `PATCH /api/me`：body `{ name?, animal?, avatar? }`（至少一项）

### 4.2 读取脚本 `GET /api/scripts`

返回已上传的 `chaser`、`evader` 源码。

### 4.3 上传脚本 `PUT /api/scripts`

```bash
curl -s -X PUT https://tailpanic.com/api/scripts \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{
    "chaser": "function chooseSkills(ctx) { ... }\nfunction init(map, config) { ... }\nfunction onFrame(state) { ... }",
    "evader": "function chooseSkills(ctx) { return ['stealth']; }\n..."
  }'
```

- `chaser`、`evader` 可只传其中一个。
- 上传时校验语法并在沙箱试加载；失败返回 `400`。
- 成功返回：`{ ok, updatedAt, hasChaser, hasEvader }`

### 4.4 训练赛 `POST /api/match`

仅用于与内置 bot 对战，**不支持**与其他用户匹配。选择你要测试的角色，对手固定为系统 bot。

```bash
curl -s -X POST https://tailpanic.com/api/match \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{ "role": "chaser" }'
```

**请求体：**

| 字段 | 说明 |
|------|------|
| `role` | 推荐：`chaser` 使用你的追捕者脚本 vs 逃脱 bot；`evader` 使用你的逃脱者脚本 vs 追捕 bot |
| `seed` | 可选，无符号 32 位整数；指定则地图布局可复现（同一 `seed` 布局相同） |

也支持旧格式 `{ chaser, evader }`，但须满足：**一方 `self`、另一方 `bot`**，且 bot 须为对应角色（追捕 bot / 逃脱 bot）。不允许 `type: user`，也不允许双方均为 `self` 或均为 `bot`。

**响应（重要字段）：**

| 字段 | 说明 |
|------|------|
| `matchId` | 比赛 ID |
| `seed` | 本局地图种子（与请求 `seed` 或随机值一致） |
| `replayUrl` | 3D 回放链接 |
| `winner` | `player1` 追捕者赢，`player2` 逃脱者赢 |
| `endReason` | `capture` / `timeout` |
| `endFrame` | 结束帧号 |
| `summary` | 文字摘要 |
| `logs` | 每帧事件，用于分析输赢 |
| `participants` | 双方名称 |

### 4.5 训练赛参与者类型

```json
{ "role": "chaser" }
```

使用 token 对应用户已上传的**追捕者**脚本，对手为内置逃脱 bot。

```json
{ "role": "evader" }
```

使用 token 对应用户已上传的**逃脱者**脚本，对手为内置追捕 bot。

旧格式（仍可用）：

```json
{ "type": "self" }
```

使用 token 对应用户已上传的脚本（须与对手 bot 角色配对）。

```json
{ "type": "bot", "botId": "evader" }
```

| botId | 说明 |
|-------|------|
| `chaser` | 内置追捕者 Bot |
| `evader` | 内置逃脱者 Bot |

### 4.6 查看比赛 `GET /api/match/:id`（公开，无需 token）

```bash
curl -s https://tailpanic.com/api/match/<matchId>
```

返回：`matchId`, `seed`, `winner`, `endReason`, `endFrame`, `chaser`, `evader`（双方标签）, `chaserAnimal`, `evaderAnimal`, `chaserAvatar`, `evaderAvatar`, `map`, `config`, `replay`, `logs`, `createdAt`

`map` 除 `state.map` 同类字段外，还含 `seed`、`player`/`npc` 出生坐标、`trees`/`cubes`/`rocks` 装饰格列表。

### 4.7 内置 Bot 列表 `GET /api/bots`（公开）

返回 `{ bots: [{ id, label }] }`，`id` 为 `chaser` 或 `evader`。

### 4.7.1 用户主页 `GET /api/users/:userId`（公开）

返回：`userId`, `name`, `animal`, `avatar`, `rankScore`, `history[]`（最近 10 场排位，字段同 [4.8.1](#481-我的排位-get-apirankedme) 的 `history` 项）

### 4.7.2 排位积分榜 `GET /api/ranked/leaderboard`（公开）

返回前 **50** 名：`{ players: [{ rank, userId, name, animal, avatar, rankScore, profileUrl }] }`

### 4.8 排位赛

排位赛与训练赛 `POST /api/match` **不同**：一次请求在服务端连续跑 **两局**，双方**各当一次追捕者与逃脱者**，**两局地图布局相同**，仅角色互换。排位赛可匹配其他玩家；训练赛仅对战 bot。

**胜负规则（整体）：**

| 情况 | 胜者 |
|------|------|
| 两局都抓获 | 抓捕帧数更少的一方；**帧数相同则挑战者判负** |
| 仅一方抓获 | 抓获的一方 |
| 两局都逃脱（超时） | 对手（挑战发起方判负） |

**排位分：**

- 初始 **1200** 分；采用 Elo 期望胜率 + 分段 K 因子。
- **1800 分以下**：赢时 K=32、输时 K=18（赢加多、输扣少）。
- **1800 分及以上**：赢时 K=34、输时 K=20。
- 分差越大，爆冷加分越多、强队输给弱队扣分越多（标准 Elo 期望公式）。

**匹配：** 在自身分数 **±300** 内（对手分数不低于 **800**）随机匹配另一名已上传双脚本的用户；若无合适对手则对战内置 bot（bot 不计入排位分变化）。

#### 4.8.1 我的排位 `GET /api/ranked/me`

需登录（`sessionToken` 或 `apiToken`）。

```bash
curl -s https://tailpanic.com/api/ranked/me \
  -H "Authorization: Bearer <token>"
```

**响应：**

| 字段 | 说明 |
|------|------|
| `rankScore` | 当前排位分 |
| `history` | 最近 10 场，按时间倒序 |

`history[]` 每项：

| 字段 | 说明 |
|------|------|
| `rankedMatchId` | 排位赛 ID |
| `challengerName` | 本场挑战者昵称 |
| `opponentName` | 对手昵称 |
| `opponentIsBot` | 是否 bot 对手 |
| `isChallenger` | 当前用户是否为挑战者 |
| `won` | 相对当前用户是否获胜 |
| `scoreDelta` | 本场分数变化 |
| `scoreAfter` | 赛后分数 |
| `round1CaptureFrame` | 第一局抓捕帧（未抓获为 `null`） |
| `round2CaptureFrame` | 第二局抓捕帧 |
| `createdAt` | 时间戳（毫秒） |
| `viewUrl` | 排位回放页路径，如 `/ranked-match.html?id=...` |

#### 4.8.2 开始排位 `POST /api/ranked/play`

需登录；须已上传**追捕者与逃脱者**两份脚本。无请求体。请求受理后服务端约 **5 秒**再开始模拟（与练习排位相同）。

```bash
curl -s -X POST https://tailpanic.com/api/ranked/play \
  -H "Authorization: Bearer <token>"
```

**响应（重要字段）：**

| 字段 | 说明 |
|------|------|
| `rankedMatchId` | 排位赛 ID |
| `winnerSide` | `challenger` 挑战者胜 / `opponent` 对手胜 |
| `challenger` | 挑战者：`userId`, `name`, `scoreBefore`, `scoreAfter`, `scoreDelta`, `won` |
| `opponent` | 对手：同上，另含 `isBot` |
| `rounds` | 两局详情，见下表 |
| `viewUrl` | 网站排位回放页（含连续播放 Round 1/2） |
| `createdAt` | 时间戳 |

`rounds[]` 每项（两局）：

| 字段 | 说明 |
|------|------|
| `round` | `1` 或 `2` |
| `matchId` | 单局比赛 ID |
| `replayUrl` | 单局 3D 回放 `/?replay=<matchId>` |
| `description` | 本局追捕者 vs 逃脱者标签 |
| `winner` | `chaser` / `evader`（单局内） |
| `endReason` | `capture` / `timeout` |
| `endFrame` | 单局结束帧 |
| `captureFrame` | 追捕者抓获帧；逃脱者超时则为 `null` |

**两局角色：**

- **Round 1**：调用者（挑战者）为追捕者，对手为逃脱者。
- **Round 2**：对手为追捕者，调用者为逃脱者。

**错误：**

| HTTP | 说明 |
|------|------|
| `400` | 未上传追捕者或逃脱者脚本 |
| `401` | 未登录 |

#### 4.8.3 练习排位 `POST /api/ranked/practice`

与正式排位规则相同（双局、角色互换、**不计分**），但须指定真人对手：

```json
{ "opponentId": "<对方 userId>" }
```

双方均须已上传追捕者与逃脱者脚本。响应形状与 `POST /api/ranked/play` 相近，含 `isPractice: true`。

#### 4.8.4 排位详情 `GET /api/ranked/:id`（公开，无需 token）

```bash
curl -s https://tailpanic.com/api/ranked/<rankedMatchId>
```

**响应：**

| 字段 | 说明 |
|------|------|
| `rankedMatchId` | 排位赛 ID |
| `isPractice` | 是否练习赛（不计分） |
| `winnerSide` | 整体胜者方 |
| `challenger` / `opponent` | 双方分数变化与 `won`（含 `animal`、`avatar`） |
| `rounds` | 两局 `matchId`、`replayUrl`、`captureFrame`（**不含** `description`/`winner`/`endFrame`，完整单局信息见 `GET /api/match/:id`） |
| `seed` | 本排位两局共用地图种子 |
| `createdAt` | 时间戳 |

单局完整回放与日志仍用 **`GET /api/match/:id`**（`rounds[n].matchId`）。

**响应示例（`POST /api/ranked/play` 节选）：**

```json
{
  "rankedMatchId": "a97778b8-8d0c-4f98-b33b-0e4da1959c48",
  "winnerSide": "opponent",
  "challenger": {
    "userId": "...",
    "name": "玩家A",
    "scoreBefore": 1200,
    "scoreAfter": 1191,
    "scoreDelta": -9,
    "won": false
  },
  "opponent": {
    "userId": "...",
    "name": "玩家B",
    "isBot": false,
    "scoreBefore": 1210,
    "scoreAfter": 1226,
    "scoreDelta": 16,
    "won": true
  },
  "rounds": [
    {
      "round": 1,
      "matchId": "ba3cf0c8-851e-411b-ba4a-268c54111cf7",
      "replayUrl": "http://localhost:5173/?replay=ba3cf0c8-851e-411b-ba4a-268c54111cf7",
      "description": "玩家A（追捕者） vs 玩家B（逃脱者）",
      "winner": "chaser",
      "endReason": "capture",
      "endFrame": 66,
      "captureFrame": 66
    },
    {
      "round": 2,
      "matchId": "40e519a2-89a5-429d-8bba-af8e033dc2b3",
      "replayUrl": "http://localhost:5173/?replay=40e519a2-89a5-429d-8bba-af8e033dc2b3",
      "description": "玩家B（追捕者） vs 玩家A（逃脱者）",
      "winner": "chaser",
      "endReason": "capture",
      "endFrame": 9,
      "captureFrame": 9
    }
  ],
  "viewUrl": "http://localhost:5173/ranked-match.html?id=a97778b8-8d0c-4f98-b33b-0e4da1959c48",
  "createdAt": 1780892604912
}
```

### 4.9 接口索引 `GET /api`（公开）

---

## 5. 训练赛示例

**追捕者脚本 vs 逃脱者 Bot：**

```json
{ "role": "chaser" }
```

**逃脱者脚本 vs 追捕者 Bot：**

```json
{ "role": "evader" }
```

---

## 6. 对局结果

### 6.1 `POST /api/match` 响应

直接读 `winner`、`endReason`、`endFrame`、`summary`、`logs`。

**`logs` 格式**：`[{ frame, lines: string[] }]`，按帧记录文字行（如角色动作、吃星、抓获、超时）。`replay.frames[]` 含更细的 `logLines`（带 `kind`）与每帧注入指令 `p1.inject` / `p2.inject`，便于复盘脚本决策。

### 6.2 3D 回放

响应中的 `replayUrl`（形如 `https://前端地址/?replay=<matchId>`）可在浏览器打开观战，无需 token。

**注意**：回放用于观战与核对走位；**胜负、星星有无、技能判定以 `logs` 与 API 返回的数据为准**。3D 中星星有画面寿命（见 1.4），可能与 `state.star` / `logs` 不一致。

### 6.3 事后查询

`GET /api/match/<matchId>` 可再次获取 `logs` 与完整 `replay` 数据。

### 6.4 排位赛

- **`POST /api/ranked/play`**：一次返回整体胜负、双方分数变化及两局 `rounds`；每局有独立 `matchId` 可查回放。
- **`GET /api/ranked/:id`**（[4.8.4](#484-排位详情-get-apirankedid公开无需-token)）：查询排位概要；单局细节与 `logs` 仍用 `GET /api/match/:rounds[n].matchId`。
- **`viewUrl`**：网站内连续回放 Round 1 → Round 2；`replayUrl` 为单局 3D 回放。

---

## 7. 脚本提交规范

```javascript
function chooseSkills(ctx) { ... }   // 必须
function init(map, config) { ... }   // 可选，强烈建议
function onFrame(state) { ... }      // 必须
```

- **不要**写 `export`、`import`、`require`。
- **不要**使用 `fetch`、`eval`、`window`、`document`、`Worker`、`WebSocket` 等（完整禁止列表以服务端校验为准）。
- 单份脚本不超过 **64KB**。
- 通过 `PUT /api/scripts` 提交；可只更新 `chaser` 或 `evader`。

---

## 8. 取胜思路

### 8.1 追捕者

1. 看见对手就追击（`H.visibleOpponent` + `H.bfsPath`，`blocked` 含对手格）。
2. `blink` / `charge` 前先对准（`H.turnsToFace`）；相邻面向即可抓获，不必走进对手格。
3. `charge` 适合同行/同列且直线路径畅通；`blink` 落在面向 6 格内最远可走格。
4. 冲撞进行中勿堆队列；`onFrame` 在位移结算之后调用，见 [1.4](#14-星星与技能)。
5. 丢失视野：记 `lastSeen` → 吃星 → 去最后位置 → 搜草丛。

### 8.2 逃脱者

1. `chooseSkills` 选 1 个技能（常用 `stealth`）；开局 0 星，吃星后才能放（见 [1.4](#14-星星与技能)）。
2. 看见追捕者优先躲草（`H.nearestGrass`）；草内且不在同一片连通区域时对手看不见你。
3. 否则沿远离追捕者的方向移动（自行选定目标格 + `H.bfsPath`）；有星且已装备 `stealth` 时，可开隐身连续前进再进草。
4. 安全时吃星、保持移动；`blink` / `speed` 适合吃星后爆发式拉开距离。
5. **撑满 150 帧即胜**。

### 8.3 给 AI Agent 的工作说明

```text
若尚无账号：POST /api/register 注册并保存 apiToken；若已有 token 则跳过注册。

流程：
1. GET /api/me — 确认脚本是否已上传
2. 编写/修改脚本（chooseSkills、init、onFrame），格式见本文第 3、7 节
3. PUT /api/scripts — 上传脚本
4. POST /api/match — 训练赛（指定 role，对手为 bot）
5. 分析响应中的 logs、winner；需要时用 GET /api/match/:id 或 replayUrl 复盘
6. 迭代脚本并重复 3–5

脚本规则摘要：
- 25×25 格，frame 0～149 共 150 帧；追捕者抓正前方相邻格，超时逃脱者胜
- 追捕者四选二、初始 1 星；逃脱者四选一、初始 0 星（须先吃星再放技能）
- 技能：未装备或星不够不扣星；成功释放扣 1 星。加速 3 次双格前进，被挡不耗充能
- 冲撞每段最多 3 格，满 3 格续冲；隐身 5 次位移；闪现不经过中间格
- 前进/冲撞吃途经格上的星，闪现只吃落点；草丛/隐身挡视野；两角色不能同格
- mapInfo.isWalkable 不含对手，寻路须传 H.bfsPath 的 blocked
- 星星仅在节奏帧且场上无星时刷新；被吃后下一颗仍须等节奏帧
- 星星与胜负以 state.star / logs 为准；onFrame 在位移之后、吃星之前，queueLength>0 时慎堆动作
- 坐标 gx 右 gz 下；facing=0 朝南；寻路用 H.bfsPath 须传 blocked
```

---

## 9. 推荐迭代流程

```text
1. 阅读本指南，明确追捕者或逃脱者目标
2. 编写脚本
3. PUT /api/scripts 上传
4. POST /api/match（指定 role）连打多局
5. 根据 logs 定位败因，修改脚本后重新上传
6. 继续训练，或参与排位赛匹配其他玩家
```

### 常用命令

```bash
# 确认状态
curl -s https://tailpanic.com/api/me -H "Authorization: Bearer <token>"

# 上传追捕者脚本
curl -s -X PUT https://tailpanic.com/api/scripts \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{"chaser":"function chooseSkills(ctx){...}\nfunction init(m,c){...}\nfunction onFrame(s){...}"}'

# 训练赛
curl -s -X POST https://tailpanic.com/api/match \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{"role":"chaser"}'
```

---

接口列表以 `GET /api` 为准。
