# AgentDuel 1v1 死斗 Agent 编写指南

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

## 模式边界

死斗模式双方各一个单位，最多 100 回合：

- 使用 `Agent`。
- 使用 `Observation & ObservationHelpers`。
- 当前单位读取 `observation.self`。
- 当前可见敌人读取 `observation.visibleEnemy`。
- 固定导出 `classId` 和 `agent`。

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

## 代码运行时间

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

## 自动注入的死斗内容

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

| 类型 | 用途 |
|------|------|
| `Agent` | 死斗 Agent 生命周期和决策接口 |
| `GameStartContext` | `onGameStart()` 的开局上下文 |
| `Observation` | 死斗观测属性 |
| `ObservationHelpers` | 死斗观测辅助方法 |
| `ObservableUnit` | 己方单位结构 |
| `TurnResult` | `onTurnEnd()` 的回合结果 |
| `GameResult` | `onGameEnd()` 的对局结果 |

| 运行时名称 | 精确签名 | 用途 |
|------------|----------|------|
| `moveToward` | `(position, target, canMove, simulateMove, random) => Action \| null` | 向目标选择一步移动；没有合法移动时返回 `null`。 |
| `searchAction` | `(position, getLastKnownEnemyPosition, enemySpawn, canMove, simulateMove, random) => Action` | 敌人不可见时搜索；无法移动时返回等待。 |

## 固定导出

`classId` 写成提交角色的职业：

| 提交角色职业 | `classId` |
|--------------|-----------|
| 战士 | `"warrior"` |
| 法师 | `"mage"` |
| 猎人 | `"hunter"` |

最小结构如下，示例中使用法师；提交战士或猎人角色时替换 `classId`：

```ts
export const classId = "mage";

export function agent({ random }: { random: () => number }): Agent {
  return {
    decideAction(observation) {
      void observation;
      void random;
      return { type: "wait" };
    }
  };
}
```

## 观测对象

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

interface Observation {
  turn: number;
  tick: number;
  maxTurn: number;
  usedTime: number;
  map: GameMapObservation;
  memory: string;
  self: ObservableUnit;
  enemyBuffs: Buff[];
  visibleEnemy: VisibleEnemy | null;
  terrain: readonly (readonly Terrain[])[];
  visibleEffects: Effect[];
  lastEvents: EventLog[];
}

interface GameMapObservation {
  width: number;
  height: number;
  spawnPoints: Record<Side, Position>;
}
```

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

常用字段：

- 自己：`observation.self`
- 可见敌人：`observation.visibleEnemy`
- 当前小节：`observation.tick`；回合开始为 `0`，正式决策从 `1` 开始
- 敌方公开 buff：`observation.enemyBuffs`
- 己方累计决策耗时：`observation.usedTime`，单位毫秒
- 地图效果：`observation.visibleEffects`
- 最近事件：`observation.lastEvents`

状态读取按观测对象区分：己方完整状态使用 `observation.self.buffs` 和 `observation.self.debuffs`；可见敌人的公开状态使用 `observation.visibleEnemy.visibleBuffs` 和 `observation.visibleEnemy.visibleDebuffs`。

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

```ts
const selfIsSlowed = observation.self.debuffs.some(
  (effect) => effect.kind === "slow"
);
const enemyIsRooted = observation.visibleEnemy?.visibleDebuffs.some(
  (effect) => effect.kind === "root"
) ?? false;
// cooldowns 是以 skillId 为键的对象，不是数组；敌人不可见或没有该冷却时按 0 处理。
// 敌方 cooldowns 永远不包含 freezingTrap。
const enemyFireballCooldown = observation.visibleEnemy?.cooldowns["fireball"] ?? 0;
```

`visibleEnemy === null` 表示当前看不到敌人。敌人不可见时，搜索策略使用 `observation.getLastKnownEnemyPosition()` 或 `observation.map.spawnPoints[enemySide(observation.self.side)]`。

猎人的 `silencingShot`、`serpentSting` 和 `bowAttack` 射程均为 2 到 4 格；距离对方1的时候为盲区，无法释放弓箭普攻或攻击技能，应优先用 `disengage` 或普通移动重新拉开距离。

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

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

## 辅助方法精确签名

```ts
interface ObservationHelpers {
  isSelfHidden(): boolean;
  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(direction: Direction): boolean;
  simulateMove(from: Position, direction: Direction): Position;
  getReachablePositions(maxAp: number): Position[];
  canUseSkill(
    skillId: string,
    targetId?: string,
    targetPosition?: Position,
    direction?: Direction,
    facing?: Direction
  ): boolean;
  getSkillInfo(skillId: string): SkillInfo;
  getLastKnownEnemyPosition(): Position | null;
}
```

| 方法 | 返回语义和边界 |
|------|----------------|
| `isSelfHidden()` | 仅在己方当前草丛隐藏仍生效时返回 `true`；离开草丛、相邻显形、超时或全局强制显形后返回 `false`。 |
| `getTerrainAt(position)` | 返回格子地形；坐标出界时返回 `null`。 |
| `isInsideMap(position)` | 判断坐标是否在地图范围内。 |
| `isWalkable(position)` | 只判断地形能否通行，不判断单位占位。 |
| `blocksVision(position)` | 判断该格地形是否阻挡视线。 |
| `distance(a, b)` | 返回 Chebyshev 距离。 |
| `hasLineOfSight(a, b)` | 按墙体和墙角规则判断两点视线。 |
| `canMove(direction)` | 按己方当前状态、行动力、地形和当前占位校验一步移动。 |
| `simulateMove(from, direction)` | 只模拟地形移动；无法通过地形时返回原坐标，不校验单位占位。 |
| `getReachablePositions(maxAp)` | 返回给定行动力内可达坐标，包含当前位置，并排除当前敌方占位。 |
| `canUseSkill(...)` | 按当前观测对应状态校验职业、行动力、冷却、目标、射程、视线、方向和可选转向。 |
| `getSkillInfo(skillId)` | 返回公共 `SkillInfo`；传入未知技能 ID 会抛出错误。 |
| `getLastKnownEnemyPosition()` | 敌人当前可见时返回当前位置，否则返回最近一次可见敌方移动事件的终点；没有记录时返回 `null`。 |

`observation.isSelfHidden()` 读取引擎的当前角色隐藏状态。站在草丛格不等于已经隐藏；只有当前隐藏仍生效时才返回 `true`。

```ts
if (observation.isSelfHidden()) {
  return { type: "wait" };
}
```

死斗移动和技能校验写法：

```ts
observation.canMove(direction);
observation.simulateMove(observation.self.position, direction);
observation.getReachablePositions(observation.self.ap);
observation.canUseSkill("fireball", enemy.id);
```

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

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

`blink` 推荐写法：

```ts
const direction = directionTo(observation.self.position, targetPosition);
if (observation.canUseSkill("blink", undefined, undefined, direction, direction)) {
  return { type: "skill", skillId: "blink", direction, facing: direction };
}
```

猎人 `disengage` 推荐写法：

```ts
const facing = directionTo(observation.self.position, enemy.position);
if (observation.canUseSkill("disengage", undefined, undefined, undefined, facing)) {
  return { type: "skill", skillId: "disengage", facing };
}
```

## 死斗专用工具

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

```ts
/** 向目标坐标移动一步。遍历随机打乱的方向，返回可接近目标的最优移动行动。无可走方向时返回 null */
declare function moveToward(
  position: Position,
  target: Position,
  canMove: (direction: Direction) => boolean,
  simulateMove: (from: Position, direction: Direction) => Position,
  random: () => number
): Action | null;

/** 敌人不可见时执行搜索：优先向 getLastKnownEnemyPosition() 前进，无记录时向 enemySpawn 前进。总是返回行动 */
declare function searchAction(
  position: Position,
  getLastKnownEnemyPosition: () => Position | null,
  enemySpawn: Position,
  canMove: (direction: Direction) => boolean,
  simulateMove: (from: Position, direction: Direction) => Position,
  random: () => number
): Action;
```

敌人不可见时，`searchAction()` 会优先前往最后已知位置，否则前往传入的敌方出生点：

```ts
searchAction(
  observation.self.position,
  observation.getLastKnownEnemyPosition,
  observation.map.spawnPoints[enemySide(observation.self.side)],
  observation.canMove,
  observation.simulateMove,
  random
);
```

## 生命周期

```ts
interface GameStartContext {
  side: Side;
  classId: ClassId;
  maxTurn: number;
  map: GameMapObservation;
  terrain: readonly (readonly Terrain[])[];
}

interface Agent {
  onGameStart?(context: GameStartContext): void;
  onTurnStart?(observation: Observation): void;
  decideAction(observation: Observation & ObservationHelpers): Action;
  onTurnEnd?(result: TurnResult): void;
  onGameEnd?(result: GameResult): void;
}
```

`TurnResult` 和 `GameResult` 的字段如下：

| 类型 | 字段 |
|------|------|
| `TurnResult` | `turn: number`、`events: EventLog[]`、`state: GameState`。 |
| `GameResult` | `winner: Side \| "draw" \| null`、`reason: "death" \| "maxTurn" \| "draw" \| "forfeit" \| "scoreLimit" \| "timeout" \| "inactivity"`、`finalTurn: number`、`finalState: GameState`、`replay: EventLog[]`。 |

`GameState` 是框架内部状态类型，不会单独自动注入。生命周期参数直接使用系统注入的 `GameStartContext`、`TurnResult`、`GameResult`，或让 `Agent` 根据回调位置推断参数类型。

只有 `decideAction()` 返回行动。每个 tick 返回一个 `Action`；其他生命周期方法只接收通知，不返回行动。

## 死斗策略要求

### 敌人可见

1. 遍历 `ATTACK_SKILLS[observation.self.classId]`。
2. 每次释放前调用 `observation.canUseSkill(skillId, enemy.id)`。
3. 技能不可用时，根据职业射程接近或拉开。
4. 猎人和法师距离 1 时优先控制、位移或后撤。

### 敌人不可见

1. 使用 `getLastKnownEnemyPosition()`。
2. 没有最后已知位置时，搜索 `observation.map.spawnPoints[enemySide(observation.self.side)]`。
3. 最近发生碰撞时，选择新的目标点或移动方向。

### 同职业狂暴

同职业对战时，第 1 回合生成狂暴拾取点。通过 `visibleEffects` 找到：

```ts
const berserkPickup = observation.visibleEffects.find(
  (effect): effect is BuffPickupEffect =>
    effect.type === "buffPickup" && effect.kind === "berserk"
);

if (berserkPickup !== undefined) {
  const target = berserkPickup.position;
}
```

拾取 `berserk` 后，造成和受到的伤害都会增加 1。

### 防止平局

- 连续 15 回合无伤害会判平。
- 连续 10 回合整回合只有等待会判平。
- 猎人理想攻击距离为 2-4 格；距离 1 时优先 `disengage` 或后撤。
- 法师贴脸且无法攻击时优先 `blink`、控制或后撤。
- 发生 `collision` 后改变移动目标或方向。

## 完整可提交示例

以下示例职业固定为法师。标记用于自动测试，请勿删除。

<!-- BEGIN:DEATHMATCH_FULL_EXAMPLE -->
```ts
export const classId = "mage";

type Obs = Observation & ObservationHelpers;

function moveAway(obs: Obs, threat: Position, random: () => number): Action | null {
  const target = {
    x: obs.self.position.x * 2 - threat.x,
    y: obs.self.position.y * 2 - threat.y
  };
  return moveToward(
    obs.self.position,
    target,
    obs.canMove,
    obs.simulateMove,
    random
  );
}

function decide(obs: Obs, random: () => number): Action {
  const enemy = obs.visibleEnemy;

  if (enemy !== null) {
    for (const skillId of ATTACK_SKILLS[obs.self.classId] ?? []) {
      if (obs.canUseSkill(skillId, enemy.id)) {
        return { type: "skill", skillId, targetId: enemy.id };
      }
    }

    if (obs.distance(obs.self.position, enemy.position) <= 1) {
      const retreat = moveAway(obs, enemy.position, random);
      if (retreat !== null) return retreat;
    }

    const approach = moveToward(
      obs.self.position,
      enemy.position,
      obs.canMove,
      obs.simulateMove,
      random
    );
    if (approach !== null) return approach;
  }

  return searchAction(
    obs.self.position,
    obs.getLastKnownEnemyPosition,
    obs.map.spawnPoints[enemySide(obs.self.side)],
    obs.canMove,
    obs.simulateMove,
    random
  );
}

export function agent({ random }: { random: () => number }): Agent {
  return {
    decideAction(observation: Obs) {
      return decide(observation, random);
    }
  };
}
```
<!-- END:DEATHMATCH_FULL_EXAMPLE -->

## 死斗提交检查

1. `classId` 与提交角色职业一致。
2. 固定导出 `agent({ random })`。
3. 决策读取 `observation.self` 和 `observation.visibleEnemy`。
4. 移动和技能辅助方法使用死斗签名。
5. 敌人不可见时使用最后已知位置或敌方出生点搜索。
6. 猎人或法师贴脸时优先拉开距离。
7. 碰撞后改变走位。
8. 长期无目标时保持搜索或调整站位。
