# AgentDuel 2v2 夺旗 TeamAgent 编写指南

生成夺旗代码前先读取 [公共入口与规则](./AGENT_CODE_GUIDE.md)。本文件只描述 2v2 夺旗模式。

## 模式边界

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

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

源码提交、编译状态轮询、编译成功后返回网站开始对战链接，以及创建对战的 HTTP 调用见 [AGENT_SUBMISSION_GUIDE.md](./AGENT_SUBMISSION_GUIDE.md)。

## 代码运行时间

- 每场对战中，双方各有 2000ms 的总计代码运行时长，只累计 `decideActions()`。
- 每个大回合包含多个小节；每个小节需要行动时会调用一次 `decideActions()`，单次最多运行 100ms。
- `observation.tick` 是当前小节编号；`onTurnStart` 收到 `0`，`decideActions()` 从 `1` 开始递增。
- `observation.usedTime` 返回本次调用前己方已经使用的累计毫秒数，首次为 `0`，不会暴露对方耗时。
- `observation.lastEvents` 不包含回放专用的 `agentDecision`、双方耗时、`stateSnapshot` 或 v2 精确失败诊断；对手的 `invalidAction` 不下发，敌方未触发的冰冻陷阱及其释放、放置和冷却事件也不可见。
- 任一单位返回 `{ type: "giveup" }` 都表示整队立即弃权；双方同一小节都弃权时判平，结束原因为 `forfeit`。

## 自动注入内容

公共入口列出的公共类型和 10 个公共运行时名称同样会自动注入。夺旗额外注入以下内容：

| 类型 | 用途 |
|------|------|
| `TeamAgent` | 夺旗 TeamAgent 生命周期和决策接口 |
| `TeamObservation` | 夺旗团队观测属性 |
| `TeamObservationHelpers` | 夺旗团队观测辅助方法 |
| `TeamObservableUnit` | 己方单位结构 |
| `TeamActionMap` | 以己方 `unit.id` 为键的行动映射 |
| `CaptureTheFlagObjectiveObservation` | 比分、击杀、复活点和旗帜目标信息 |
| `FlagObservation` | 旗帜状态联合类型 |

| 运行时名称 | 精确签名 | 用途 |
|------------|----------|------|
| `moveTowardTeam` | `(unitId, position, target, helpers, random) => Action \| null` | 为指定己方单位向目标选择一步移动。 |
| `randomMoveTeam` | `(unitId, position, helpers, random) => Action` | 为指定己方单位选择随机合法移动；无合法移动时等待。 |
| `orderUnitsByClass` | `(units, preferredClasses) => TeamObservableUnit[]` | 按职业偏好排序己方单位并返回新数组。 |
| `nearestUnitTo` | `(units, target, distanceFn) => T \| null` | 返回离目标最近的候选；空数组返回 `null`。 |
| `nearestVisibleEnemy` | `(from, enemies, distanceFn) => VisibleEnemy \| null` | 返回离指定位置最近的可见敌人；空数组返回 `null`。 |

## 固定导出

夺旗源码固定导出以下函数：

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

## 团队观测

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

interface TeamMapObservation {
  width: number;
  height: number;
}

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

`TeamObservation` 只包含观测属性。`onTurnStart(observation)` 收到 `TeamObservation`；每个正式决策中的 `decideActions(observation)` 收到 `TeamObservation & TeamObservationHelpers`，因此 `canMove()`、`canUseSkill()` 等辅助方法只在决策观测上使用。

`spawnAreas` 是旧 Agent 兼容字段，固定表示双方旗帜归还点数组。新代码读取 `flagReturnPoints`；单位复活位置读取 `respawnSlots`。

主要字段：

```ts
observation.side
observation.tick
observation.usedTime
observation.selfUnits
observation.visibleEnemies
observation.objective.flag
observation.objective.scores
observation.objective.respawnSlots
observation.objective.flagReturnPoints
observation.objective.flagSpawnPoints
```

## 公开状态、效果和事件字段速查

`TimedEffectKind`、`Buff`、`Debuff`、`TrapEffect`、`TimedEffect`、`BuffPickupEffect`、`Effect` 和 `EventLog` 的完整公共结构只在公共入口维护。状态判断读取 `kind`；地图效果先判断 `effect.type`；事件先判断 `event.type`。

## 己方单位和可见敌人类型

```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;
}

```

推荐类型规范：

- `selfUnits` 中的元素使用 `TeamObservableUnit`。
- `visibleEnemies` 中的元素使用 `VisibleEnemy`。
- 接收己方单位的函数参数声明为 `TeamObservableUnit`。
- 接收敌方单位的函数参数声明为 `VisibleEnemy`。
- 复用目标选择函数时，抽出公共约束，例如 `{ position: Position }`。

可见敌人的 `cooldowns` 提供允许公开的当前技能冷却。`freezingTrap` 不会出现在敌方 `cooldowns` 中，不能据此推断猎人是否放置了隐藏陷阱；己方 `selfUnits[].cooldowns` 仍包含完整冷却。

```ts
// cooldowns 是以 skillId 为键的对象，不是数组；没有可见敌人或没有该冷却时按 0 处理。
// 敌方 cooldowns 永远不包含 freezingTrap。
const enemyChargeCooldown = observation.visibleEnemies[0]?.cooldowns["charge"] ?? 0;
```

## 旗帜状态

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

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

判断持旗者使用 `FlagObservation.carrierUnitId`：

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

旗帜为 `pendingReset` 时，从 `observation.objective.flagSpawnPoints` 中选择目标点；例如用 `nearestPosition(unit.position, observation.objective.flagSpawnPoints)` 选择离当前单位最近的候选点。

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

```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;
}
```

| 方法 | 返回语义和边界 |
|------|----------------|
| `getTerrainAt(position)` | 返回格子地形；坐标出界时返回 `null`。 |
| `isInsideMap(position)` | 判断坐标是否在地图范围内。 |
| `isWalkable(position)` | 只判断地形能否通行，不判断单位占位。 |
| `blocksVision(position)` | 判断该格地形是否阻挡视线。 |
| `distance(a, b)` | 返回 Chebyshev 距离。 |
| `hasLineOfSight(a, b)` | 按墙体和墙角规则判断两点视线。 |
| `canMove(unitId, direction)` | 按指定己方单位的当前状态、行动力、地形和当前占位校验一步移动。 |
| `simulateMove(from, direction)` | 只模拟地形移动；无法通过地形时返回原坐标，不校验单位占位。 |
| `getReachablePositions(unitId, maxAp)` | 返回指定单位在行动力内可达坐标并包含当前位置；未知单位 ID 返回空数组。 |
| `canUseSkill(...)` | 按指定单位当前状态校验职业、行动力、冷却、目标、射程、视线、方向和可选转向。 |
| `getSkillInfo(skillId)` | 返回公共 `SkillInfo`；传入未知技能 ID 会抛出错误。 |
| `getLastKnownEnemyPosition(enemyUnitId?)` | 传 ID 时返回该敌人的当前或最近移动位置；省略时优先第一个可见敌人，否则返回最近一次敌方移动终点；没有记录时返回 `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` 使用当前位置和方向：

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

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

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

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

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

## 团队专用工具

以下五个夺旗专用工具由系统自动注入，直接调用。`directionTo` 是公共工具，其签名和边界只在公共入口维护。

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

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

/** 按 preferredClasses 中的职业顺序排列单位；未列出的职业排在后面，同职业按 id 排序。返回新数组 */
declare function orderUnitsByClass(
  units: readonly TeamObservableUnit[],
  preferredClasses: readonly ClassId[]
): TeamObservableUnit[];

/** 获取距离指定位置最近的可见敌人 */
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)
);

nearestUnitTo(
  observation.visibleEnemies,
  observation.objective.flagSpawnPoints[0] ?? unit.position,
  (a, b) => observation.distance(a, b)
);
```

按职业偏好安排单位处理顺序：

```ts
const orderedUnits = orderUnitsByClass(
  observation.selfUnits,
  ["hunter", "warrior", "mage"]
);
```

## 生命周期

`TeamAgent` 支持 `onTurnStart()`、`decideActions()`、`onTurnEnd()` 和 `onGameEnd()`。只有 `decideActions()` 返回行动映射：

```ts
interface TeamAgent {
  onTurnStart?(observation: TeamObservation): void;
  decideActions(
    observation: TeamObservation & TeamObservationHelpers
  ): TeamActionMap;
  onTurnEnd?(result: TurnResult): void;
  onGameEnd?(result: GameResult): void;
}
```

生命周期方法直接写在 `teamAgent()` 返回的对象中，让 TypeScript 从 `TeamAgent` 推断 `TurnResult` 和 `GameResult` 参数；夺旗源码当前不会单独自动注入这两个类型名。

回调参数包含以下字段：

| 回调参数 | 字段 |
|----------|------|
| `onTurnStart(observation)` | 完整 `TeamObservation`；此时 `tick === 0`，不带辅助方法。 |
| `decideActions(observation)` | `TeamObservation & TeamObservationHelpers`；返回 `TeamActionMap`。 |
| `onTurnEnd(result)` | `turn: number`、`events: EventLog[]`、`state: GameState`。 |
| `onGameEnd(result)` | `winner: Side \| "draw" \| null`、`reason: "death" \| "maxTurn" \| "draw" \| "forfeit" \| "scoreLimit" \| "timeout" \| "inactivity"`、`finalTurn: number`、`finalState: GameState`、`replay: EventLog[]`。 |

`GameState` 是框架内部状态类型，也不会单独自动注入；`onTurnEnd()` 和 `onGameEnd()` 只接收通知，不返回行动。

## 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;
```

缺少某个可行动单位时，该单位会执行等待。明确需要战术等待时，为该单位返回 `{ type: "wait" }`。

## 夺旗规则

- 旗帜在地图配置的候选点中刷新；固定刷新地图也会提供一个候选点。
- 单位走到旗帜位置会拾旗。
- 旗手到达己方 `flagReturnPoints[side]` 得分。
- 先得 3 分获胜。
- 旗手死亡时旗帜通常掉落在死亡位置。
- 旗手死亡在己方出生区域时可能直接完成送旗得分。
- 得分后旗帜进入 `pendingReset`，下一回合重置。
- 死亡单位下一回合复活，满血且冷却清零。
- 旗手离开草丛隐藏状态。
- 出生区域允许己方多人同格。

## 防止平局

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

## 完整可提交示例

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

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

function flagTarget(obs: TeamObs, from: Position): Position {
  const flag = obs.objective.flag;
  return flag.state === "pendingReset"
    ? nearestPosition(from, obs.objective.flagSpawnPoints)
    : 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 = obs.objective.flagReturnPoints[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, unit.position),
    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: TeamObs) {
      const obs = observation;
      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. 决策读取 `side`、`selfUnits`、`visibleEnemies` 和 `objective`。
3. 己方参数使用 `TeamObservableUnit`，敌方参数使用 `VisibleEnemy`。
4. 通过 `flag.state` 和 `flag.carrierUnitId` 判断持旗者。
5. 从 `flagSpawnPoints`、`flagReturnPoints` 和当前 `flag` 状态读取目标位置。
6. `canMove`、`getReachablePositions`、`canUseSkill` 传入真实 `unit.id`。
7. `simulateMove` 使用 `(from, direction)` 两个参数。
8. 位置技能把 `Position` 放在 `targetPosition` 参数。
9. 每个存活且 AP 大于 0 的单位都有行动。
10. 无目标时仍会向旗帜移动或随机移动。
