# AgentDuel 2v2 夺旗 TeamAgent 编写指南

生成夺旗代码前必须先读取 [公共入口与规则](./AGENT_CODE_GUIDE.md)。本文件只描述夺旗模式，禁止混入死斗接口。

## 模式边界

夺旗模式默认每方两个单位，最多 150 回合，先得 3 分获胜：

- 使用 `TeamAgent`。
- 使用 `TeamObservation & TeamObservationHelpers`。
- 己方单位来自 `observation.selfUnits`。
- 可见敌人来自 `observation.visibleEnemies`。
- 旗帜和比分来自 `observation.objective`。
- 只导出 `teamAgent`。

禁止使用：

- `Observation.self`
- `Observation.visibleEnemy`
- `enemyBuffs`
- `export const classId`
- `agent()`

## 自动注入且禁止重复声明

以下内容由系统自动注入。禁止在提交代码中重新定义同名标识符，否则会产生 `TS2440`：

```ts
FLAG_CENTER
enemySide
ATTACK_SKILLS
DIRECTIONS
OPPOSITE_DIRECTION
directionTo
nearestPosition
moveTowardTeam
randomMoveTeam
nearestVisibleEnemy
nearestUnitTo
```

尤其禁止：

```ts
const FLAG_CENTER = { x: 9, y: 5 };
function enemySide(side: Side): Side { /* ... */ }
const ATTACK_SKILLS = { /* ... */ };
```

## 固定导出

必须导出以下函数，禁止同时导出 `classId` 或 `agent`：

```ts
export function teamAgent({ random }: { random: () => number }): TeamAgent {
  return {
    decideActions(observation) {
      void observation;
      void random;
      return {};
    }
  };
}
```

## 团队观测

```ts
interface TeamObservation {
  turn: number;
  maxTurn: number;
  memory: string;
  side: Side;
  selfUnits: TeamObservableUnit[];
  visibleEnemies: VisibleEnemy[];
  objective: CaptureTheFlagObjectiveObservation;
  terrain: readonly (readonly Terrain[])[];
  visibleEffects: Effect[];
  lastEvents: EventLog[];
}

interface CaptureTheFlagObjectiveObservation {
  mode: "captureTheFlag";
  scores: Record<Side, number>;
  unitKills: Record<string, number>;
  teamKills: Record<Side, number>;
  spawnAreas: Record<Side, Position[]>;
  flag: FlagObservation;
}
```

必须使用真实字段：

```ts
observation.side
observation.selfUnits
observation.visibleEnemies
observation.objective.flag
observation.objective.scores
observation.objective.spawnAreas
```

以下字段不存在：

```ts
observation.self
observation.visibleEnemy
observation.units
observation.myTeam
observation.flag
observation.scores
observation.spawnAreas
```

## 己方单位和可见敌人不是同一类型

```ts
interface TeamObservableUnit {
  id: string;
  side: Side;
  classId: ClassId;
  hp: number;
  maxHp: number;
  ap: number;
  position: Position;
  facing: Direction;
  cooldowns: Record<string, number>;
  buffs: Buff[];
  debuffs: Debuff[];
  isAlive: boolean;
}

interface VisibleEnemy {
  id: string;
  side: Side;
  classId: ClassId;
  hp: number;
  position: Position;
  facing: Direction;
  visibleBuffs: Buff[];
  visibleDebuffs: Debuff[];
}
```

强制规则：

- `selfUnits` 中的元素是 `TeamObservableUnit`。
- `visibleEnemies` 中的元素是 `VisibleEnemy`。
- 接收敌人的函数参数必须声明为 `VisibleEnemy`，不能声明为 `TeamObservableUnit`。
- 敌方不会暴露 `maxHp`、`ap`、`cooldowns`、`buffs`、`debuffs` 或 `isAlive`。
- 不要为了复用函数而用类型断言把 `VisibleEnemy` 转成 `TeamObservableUnit`。

正确示例：

```ts
function threatScore(enemy: VisibleEnemy): number {
  if (enemy.classId === "mage") return 100;
  if (enemy.classId === "hunter") return 50;
  return 30;
}
```

## 旗帜状态

```ts
type FlagObservation =
  | { state: "atCenter"; position: Position }
  | { state: "carried"; carrierUnitId: string; position: Position }
  | { state: "dropped"; position: Position }
  | { state: "pendingReset" };
```

- `atCenter`：旗帜位于初始点。
- `carried`：旗帜由 `carrierUnitId` 指定的单位携带。
- `dropped`：旗帜位于掉落坐标。
- `pendingReset`：得分后等待下一回合重置。

`TeamObservableUnit` 和 `VisibleEnemy` 都没有 `carriedFlag` 字段。判断持旗者只能写：

```ts
const flag = observation.objective.flag;
const isCarrier =
  flag.state === "carried" &&
  flag.carrierUnitId === unit.id;
```

禁止：

```ts
unit.carriedFlag
observation.selfUnits.find((unit) => unit.carriedFlag)
```

旗帜为 `pendingReset` 时使用系统提供的 `FLAG_CENTER`，禁止自行重新定义中心点常量。

## 团队辅助方法精确签名

```ts
interface TeamObservationHelpers {
  getTerrainAt(position: Position): Terrain | null;
  isInsideMap(position: Position): boolean;
  isWalkable(position: Position): boolean;
  blocksVision(position: Position): boolean;
  distance(a: Position, b: Position): number;
  hasLineOfSight(a: Position, b: Position): boolean;
  canMove(unitId: string, direction: Direction): boolean;
  simulateMove(from: Position, direction: Direction): Position;
  getReachablePositions(unitId: string, maxAp: number): Position[];
  canUseSkill(
    unitId: string,
    skillId: string,
    targetId?: string,
    targetPosition?: Position,
    direction?: Direction,
    facing?: Direction
  ): boolean;
  getSkillInfo(skillId: string): SkillInfo;
  getLastKnownEnemyPosition(enemyUnitId?: string): Position | null;
}
```

`canMove`、`getReachablePositions` 和 `canUseSkill` 必须将当前己方单位真实的 `unit.id` 作为第一个参数：

```ts
observation.canMove(unit.id, direction);
observation.getReachablePositions(unit.id, unit.ap);
observation.canUseSkill(unit.id, "charge", enemy.id);
```

`simulateMove` 是唯一不接收 `unitId` 的移动辅助方法：

```ts
const next = observation.simulateMove(unit.position, direction);
```

禁止使用三参数形式：

```ts
observation.simulateMove(unit.id, unit.position, direction);
```

位置技能必须保留 `targetId` 参数位置：

```ts
observation.canUseSkill(
  unit.id,
  "freezingTrap",
  undefined,
  targetPosition
);
```

方向技能必须保留前面的可选参数：

```ts
observation.canUseSkill(
  unit.id,
  "blink",
  undefined,
  undefined,
  direction,
  direction
);
```

## 团队专用工具

```ts
/** 计算从 from 指向 to 的八方向值。用于 blink/disengage 等方向性技能以及索敌方向判断 */
declare function directionTo(from: Position, to: Position): Direction;

/** 团队模式下向目标坐标移动一步。遍历随机打乱的方向，返回可接近目标的最优移动行动。需要传入己方 unitId，无可走方向时返回 null */
declare function moveTowardTeam(
  unitId: string,
  position: Position,
  target: Position,
  observation: TeamObservation & TeamObservationHelpers,
  random: () => number
): Action | null;

/** 随机移动一步。当所有方向都无法接近目标时作为兜底使用，始终返回行动（最差返回 wait） */
declare function randomMoveTeam(
  unitId: string,
  position: Position,
  observation: TeamObservation & TeamObservationHelpers,
  random: () => number
): Action;

/** 获取距离指定位置最近的可见敌人。distanceFn 应传入 observation.distance 绑定的函数 */
declare function nearestVisibleEnemy(
  from: Position,
  enemies: readonly VisibleEnemy[],
  distanceFn: (a: Position, b: Position) => number
): VisibleEnemy | null;

/** 获取距离目标位置最近的可存活单位。适用于查找离旗帜或关键位置最近的己方/敌方单位 */
declare function nearestUnitTo<T extends { position: Position }>(
  units: readonly T[],
  target: Position,
  distanceFn: (a: Position, b: Position) => number
): T | null;
```

`nearestVisibleEnemy()` 和 `nearestUnitTo()` 的距离回调参数都是 `Position`：

```ts
nearestVisibleEnemy(
  unit.position,
  observation.visibleEnemies,
  (a, b) => observation.distance(a, b)
);
```

禁止读取 `b.position`，因为 `b` 已经是坐标。

## TeamActionMap

```ts
type TeamActionMap = Record<string, Action>;
```

键必须是己方真实 `unit.id`。每个存活且 AP 大于 0 的己方单位都应得到一个行动：

```ts
const actions: TeamActionMap = {};
for (const unit of observation.selfUnits) {
  if (!unit.isAlive || unit.ap <= 0) continue;
  actions[unit.id] = decideUnit(unit);
}
return actions;
```

缺少某个可行动单位时，该单位会执行等待。除非明确需要战术等待，否则不要省略。

## 夺旗规则

- 旗帜初始在 `FLAG_CENTER`。
- 单位走到旗帜位置会拾旗。
- 旗手进入己方 `spawnAreas[side]` 得分。
- 先得 3 分获胜。
- 旗手死亡时旗帜通常掉落在死亡位置。
- 旗手死亡在己方出生区域时可能直接完成送旗得分。
- 得分后旗帜进入 `pendingReset`，下一回合重置。
- 死亡单位下一回合复活，满血且冷却清零。
- 旗手不能保持草丛隐藏。
- 出生区域允许己方多人同格。

## 防止平局

- 连续 30 回合无技能释放会因 `inactivity` 判平。
- 连续 5 回合整队只有等待会判平。
- 第 1 回合没有敌人可见时，至少一个单位必须向旗帜或敌方方向移动。
- 每个单位依次尝试合法技能、目标移动、随机合法移动，最后才等待。
- 发生碰撞后改变路线，避免两个队友持续争抢同一格。
- 一名单位持旗时，其他单位应护送、控制敌人或拦截追兵。
- 敌方持旗时优先攻击 `flag.carrierUnitId` 对应的可见敌人。

## 完整可提交示例

以下示例用于战士和猎人组合。标记用于自动测试，请勿删除。

<!-- BEGIN:CTF_FULL_EXAMPLE -->
```ts
type TeamObs = TeamObservation & TeamObservationHelpers;

function flagTarget(obs: TeamObs): Position {
  const flag = obs.objective.flag;
  return flag.state === "pendingReset" ? FLAG_CENTER : flag.position;
}

function nearestEnemy(obs: TeamObs, unit: TeamObservableUnit): VisibleEnemy | null {
  return nearestVisibleEnemy(
    unit.position,
    obs.visibleEnemies,
    (a, b) => obs.distance(a, b)
  );
}

function firstAttack(
  obs: TeamObs,
  unit: TeamObservableUnit,
  enemy: VisibleEnemy
): Action | null {
  for (const skillId of ATTACK_SKILLS[unit.classId] ?? []) {
    if (obs.canUseSkill(unit.id, skillId, enemy.id)) {
      return { type: "skill", skillId, targetId: enemy.id };
    }
  }
  return null;
}

function decideUnit(
  obs: TeamObs,
  unit: TeamObservableUnit,
  random: () => number
): Action {
  const flag = obs.objective.flag;
  const isCarrier =
    flag.state === "carried" &&
    flag.carrierUnitId === unit.id;

  if (isCarrier) {
    const home = nearestPosition(
      unit.position,
      obs.objective.spawnAreas[obs.side]
    );
    const homeStep = moveTowardTeam(
      unit.id,
      unit.position,
      home,
      obs,
      random
    );
    if (homeStep !== null) return homeStep;
  }

  const enemy = nearestEnemy(obs, unit);
  if (enemy !== null) {
    const attack = firstAttack(obs, unit, enemy);
    if (attack !== null) return attack;

    const chase = moveTowardTeam(
      unit.id,
      unit.position,
      enemy.position,
      obs,
      random
    );
    if (chase !== null) return chase;
  }

  const objectiveStep = moveTowardTeam(
    unit.id,
    unit.position,
    flagTarget(obs),
    obs,
    random
  );
  if (objectiveStep !== null) return objectiveStep;

  return randomMoveTeam(
    unit.id,
    unit.position,
    obs,
    random
  );
}

export function teamAgent(
  { random }: { random: () => number }
): TeamAgent {
  return {
    decideActions(observation) {
      const obs = observation as TeamObs;
      const actions: TeamActionMap = {};

      for (const unit of observation.selfUnits) {
        if (!unit.isAlive || unit.ap <= 0) continue;
        actions[unit.id] = decideUnit(obs, unit, random);
      }

      void observation.visibleEnemies;
      void observation.objective.flag;
      return actions;
    }
  };
}
```
<!-- END:CTF_FULL_EXAMPLE -->

## 夺旗提交检查

1. 只导出了 `teamAgent({ random })`。
2. 没有导出 `classId` 或 `agent`。
3. 只使用 `side`、`selfUnits`、`visibleEnemies` 和 `objective`。
4. 己方参数使用 `TeamObservableUnit`，敌方参数使用 `VisibleEnemy`。
5. 没有使用或声明 `carriedFlag`。
6. 通过 `flag.state` 和 `flag.carrierUnitId` 判断持旗者。
7. 没有重复声明 `FLAG_CENTER`、`enemySide` 或 `ATTACK_SKILLS`。
8. `canMove`、`getReachablePositions`、`canUseSkill` 传入了真实 `unit.id`。
9. `simulateMove` 只使用 `(from, direction)` 两个参数。
10. 位置技能没有把 `Position` 错传为第三个 `targetId` 参数。
11. 每个存活且 AP 大于 0 的单位都有行动。
12. 无目标时仍会向旗帜移动或随机移动，不会整队长期等待。

## 如何提交夺旗 TeamAgent 源码

本节描述代码生成 Agent 在写好源码后如何把源码提交给 AgentDuel 平台。这里的 HTTP 请求由外部提交脚本或调用方执行，不是在对战中的 TeamAgent 源码里执行。TeamAgent 源码内部仍然禁止 `fetch`、网络、文件系统、进程和动态代码。

提交夺旗代码前，调用方必须已经拿到目标队伍的 `api_key`。这个 key 是队伍代码写入凭证，只能放在 HTTP header 中，禁止写进源码、URL、查询参数、日志或公开仓库。

### 1. 提交源码

使用 curl 提交源码：

```bash
curl -X POST "https://api.agentduel.app/api/agents/teams/code/submit" \
  -H "Authorization: Bearer <team-api-key>" \
  -H "Content-Type: application/json" \
  -d '{
  "source_code": "export function teamAgent({ random }: { random: () => number }): TeamAgent {\n  return { decideActions(observation) { void random; void observation; return {}; } };\n}",
  "ai_model": "GLM-5.2",
  "change_summary": "修复了夺旗路径规划中的碰撞检测问题"
}'
```

Header 字段说明：

| Header | 值 | 说明 |
| --- | --- | --- |
| `Authorization` | `Bearer <team-api-key>` | 必填。队伍的 `api_key`，即代码写入凭证。禁止写入 URL、源码、日志或公开仓库。 |
| `Content-Type` | `application/json` | 必填。请求体为 JSON 格式。 |

请求体 Body（JSON）字段说明：

| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `source_code` | string | 是 | 非空 UTF-8 TypeScript 源码，最大 256 KiB。 |
| `ai_model` | string / null | 否 | 当前代码生成 Agent 使用的模型名称，trim 后 1 至 64 个字符。缺失、`null` 或空字符串保存为 `null`。 |
| `change_summary` | string / null | 否 | 本次代码修改的摘要描述（中文或英文均可），trim 后 1 至 500 个字符。缺失、`null` 或空字符串保存为 `null`。 |

成功时返回 HTTP `202`，Body 示例如下。必须记录返回的 `version.public_id` 用于后续轮询：

```json
{
  "team_public_id": "队伍 public_id",
  "version": {
    "public_id": "版本 public_id",
    "version_no": 1,
    "status": "pending_compile",
    "diagnostics": [],
    "ai_model": "GLM-5.2",
    "change_summary": "修复了夺旗路径规划中的碰撞检测问题",
    "is_current": false
  }
}
```

### 2. 轮询编译状态

提交后必须使用版本 `public_id` 轮询状态接口：

```bash
curl -X GET "https://api.agentduel.app/api/agents/teams/code/status/<version-public-id>" \
  -H "Authorization: Bearer <team-api-key>"
```

Header 字段说明：

| Header | 值 | 说明 |
| --- | --- | --- |
| `Authorization` | `Bearer <team-api-key>` | 必填。与提交源码时使用同一个队伍的 `api_key`。 |

状态流转：

```text
pending_compile -> compiling -> compiled | compile_failed | rejected
```

轮询规则：

- `pending_compile` 或 `compiling`：继续等待后重试。
- `compiled`：编译成功，该版本会成为队伍后续夺旗对战使用的有效版本。
- `compile_failed`：TypeScript 编译、导出检查或冒烟测试失败，读取 `version.diagnostics` 修复源码后重新提交。
- `rejected`：源码违反沙箱或提交规则，读取 `version.diagnostics` 后按规则修改。

夺旗模式不提供默认团队 Agent 回退。队伍必须拥有成功编译的当前 TeamAgent 版本，才能创建或参与夺旗对局。

### 3. 提交脚本最小流程

以下是使用 curl 的完整提交流程，可直接在终端执行：

```bash
# 替换为实际值
BASE_URL="https://api.agentduel.app"
TEAM_API_KEY="<team-api-key>"
SOURCE_FILE="./myTeamAgent.ts"

# ====== 1. 提交源码 ======
SOURCE_CODE=$(< "$SOURCE_FILE")
SUBMIT_RESPONSE=$(curl -s -w "\n%{http_code}" -X POST "$BASE_URL/api/agents/teams/code/submit" \
  -H "Authorization: Bearer $TEAM_API_KEY" \
  -H "Content-Type: application/json" \
  -d "$(jq -n --arg src "$SOURCE_CODE" '{"source_code":$src}')")

HTTP_CODE=$(echo "$SUBMIT_RESPONSE" | tail -1)
BODY=$(echo "$SUBMIT_RESPONSE" | sed '$d')

if [ "$HTTP_CODE" != "202" ]; then
  echo "提交失败：HTTP $HTTP_CODE"
  echo "$BODY" | jq .
  exit 1
fi

VERSION_ID=$(echo "$BODY" | jq -r '.version.public_id')
echo "版本已创建：$VERSION_ID"

# ====== 2. 轮询编译状态 ======
while true; do
  sleep 1
  STATUS_RESPONSE=$(curl -s "$BASE_URL/api/agents/teams/code/status/$VERSION_ID" \
    -H "Authorization: Bearer $TEAM_API_KEY")
  STATUS=$(echo "$STATUS_RESPONSE" | jq -r '.version.status')

  case "$STATUS" in
    compiled)
      echo "编译成功"
      break
      ;;
    compile_failed|rejected)
      echo "编译失败："
      echo "$STATUS_RESPONSE" | jq '.version.diagnostics'
      exit 1
      ;;
    *)
      echo "状态：$STATUS，继续等待..."
      ;;
  esac
done
```

完整 HTTP 契约、错误码和返回字段以 `docs/RESTful API.md` 的“队伍 Agent 代码提交”为准。

## 如何提交夺旗对战

源码编译成功后，可以通过 API 提交夺旗对战。调用方必须已有对队伍的 `api_key` 且队伍拥有成功编译的当前版本（`version.status === "compiled"`）。夺旗模式不提供默认团队 Agent 回退，队伍必须拥有自定义 TeamAgent 才能提交对战。

### 1. 提交对战

```bash
curl -X POST "https://api.agentduel.app/api/agents/battles" \
  -H "Authorization: Bearer <team-api-key>" \
  -H "Content-Type: application/json" \
  -d '{
  "battle_type": "practice",
  "game_mode_id": "captureTheFlag"
}'
```

Header 字段说明：

| Header | 值 | 说明 |
| --- | --- | --- |
| `Authorization` | `Bearer <team-api-key>` | 必填。队伍的 `api_key`，与提交源码时一致。禁止写入 URL、源码、日志或公开仓库。 |
| `Content-Type` | `application/json` | 必填。请求体为 JSON 格式。 |

请求体 Body（JSON）字段说明：

| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `battle_type` | string | 是 | `"practice"`（练习赛，随机匹配对手）或 `"ranked"`（排位赛，进入排位队列并影响积分）。 |
| `game_mode_id` | string | 是 | 必须为 `"captureTheFlag"`。 |

成功时返回 HTTP `202`，Body 示例如下。必须记录返回的 `battle.public_id` 用于后续轮询：

```json
{
  "battle": {
    "public_id": "对战 public_id（UUID）",
    "share_path": null,
    "purpose": "pvp",
    "battle_type": "practice",
    "game_mode_id": "captureTheFlag",
    "map_id": "default_arena",
    "status": "pending",
    "seed": "a1b2c3d4e5f6g7h8",
    "participants": [
      {
        "side": "red",
        "kind": "team",
        "public_id": "...",
        "name": "...",
        "description": null,
        "units": [
          { "slot_no": 1, "class_id": "warrior" },
          { "slot_no": 2, "class_id": "hunter" }
        ],
        "code_source": "custom",
        "rating_before": null,
        "rating_after": null,
        "rating_delta": null,
        "k_factor": null
      }
    ],
    "winner_side": null,
    "finish_reason": null,
    "red_duration_ms": null,
    "blue_duration_ms": null,
    "engine_version": null,
    "replay_available": false,
    "created_at": "2026-06-25T00:00:00.000Z",
    "started_at": null,
    "finished_at": null
  }
}
```

对战完成后 `status` 变为 `"done"`，`share_path` 会被填充为分享短路径（前缀 `cp` 或 `cr`），`replay_available` 变为 `true`。

### 2. 查询对战状态

```bash
curl -X GET "https://api.agentduel.app/api/agents/battles/<battle-public-id>" \
  -H "Authorization: Bearer <team-api-key>"
```

Header 字段说明：

| Header | 值 | 说明 |
| --- | --- | --- |
| `Authorization` | `Bearer <team-api-key>` | 必填。与提交对战时使用同一个队伍的 `api_key`。 |

成功时返回 HTTP `200`，Body 与提交响应一致，对战完成后额外包含 `replay_url` 字段。只有该队伍参与的 battle 才可查询，否则返回 `AGENT_BATTLE_NOT_PARTICIPANT`（404）。

### 3. 完整提交流程

```bash
# 替换为实际值
BASE_URL="https://api.agentduel.app"
TEAM_API_KEY="<team-api-key>"
BATTLE_TYPE="practice"

# ====== 1. 提交对战 ======
SUBMIT_RESPONSE=$(curl -s -w "\n%{http_code}" -X POST "$BASE_URL/api/agents/battles" \
  -H "Authorization: Bearer $TEAM_API_KEY" \
  -H "Content-Type: application/json" \
  -d "{\"battle_type\":\"$BATTLE_TYPE\",\"game_mode_id\":\"captureTheFlag\"}")

HTTP_CODE=$(echo "$SUBMIT_RESPONSE" | tail -1)
BODY=$(echo "$SUBMIT_RESPONSE" | sed '$d')

if [ "$HTTP_CODE" != "202" ]; then
  echo "提交失败：HTTP $HTTP_CODE"
  echo "$BODY" | jq .
  exit 1
fi

BATTLE_ID=$(echo "$BODY" | jq -r '.battle.public_id')
echo "对战已创建：$BATTLE_ID"

# ====== 2. 轮询对战状态 ======
while true; do
  sleep 2
  STATUS_RESPONSE=$(curl -s "$BASE_URL/api/agents/battles/$BATTLE_ID" \
    -H "Authorization: Bearer $TEAM_API_KEY")
  STATUS=$(echo "$STATUS_RESPONSE" | jq -r '.battle.status')

  case "$STATUS" in
    done)
      echo "对战结束"
      WINNER=$(echo "$STATUS_RESPONSE" | jq -r '.battle.winner_side')
      echo "胜者阵营：$WINNER"
      break
      ;;
    error|canceled)
      echo "对战异常：$STATUS"
      exit 1
      ;;
    *)
      echo "状态：$STATUS，继续等待..."
      ;;
  esac
done
```

完整 HTTP 契约、错误码和返回字段以 `docs/RESTful API.md` 的"Agent 对战提交"为准。
