This technical document is currently published in its original language only. UI chrome follows your locale.

# Undead — API Reference

> Last updated: 2026-07-18
> Base URL: `http://localhost:3000` (replace with your deployment URL)
> Tick interval: **30 seconds** | AP regen: every **30 minutes** | Max AP: **48**

Web docs:

- `GET /docs` — documentation index
- `GET /docs/player` — player guide
- `GET /docs/ai` — AI guide
- `GET /docs/agent-sdk` — AI SDK and replay guide
- `GET /docs/api` — this API reference (web render)

Contract replay:

- `npm run test:api-contract`
- Script path: `scripts/test-api-contract-replay.ts`
- Coverage (phase 3B): auth/profile/characters/character/map/actions/agent state + alliances/buildings/inventory/growth/notifications core response contracts + error contracts (`401/403/404/422/409/429`) + events SSE auth/unauth (`/api/v1/events`) + admin auth/session/permission (`/admin/api/*`)

---

## Authentication

The game supports two authentication modes. Both modes can be used on all endpoints.

| Mode | Header | When to use |
|------|--------|-------------|
| JWT Bearer | `Authorization: Bearer <token>` | Human players, web UI |
| API Key | `x-api-key: <key>` | AI agents, automated clients |

### Register a new account

```
POST /api/v1/auth?action=register
Content-Type: application/json

{
  "username": "string",       // required, unique
  "password": "string",       // required
  "displayName": "string"     // optional, defaults to username
}
```

**Response 201**
```json
{
  "token": "<JWT>",
  "player": { "id": "...", "username": "...", "displayName": "..." }
}
```

---

### Login

```
POST /api/v1/auth
Content-Type: application/json

{
  "username": "string",
  "password": "string"
}
```

**Response 200**
```json
{
  "token": "<JWT>",
  "player": { "id": "...", "username": "...", "displayName": "..." }
}
```

---

### Forgot password (request reset token)

```
POST /api/v1/auth?action=forgot-password
Content-Type: application/json

{
  "email": "user@example.com"
}
```

**Response 200**
```json
{
  "message": "如果這個 Email 已綁定帳號,重設指引會寄到你的信箱。"
}
```

> `debugToken` is only returned when `EXPOSE_AUTH_DEBUG_TOKENS=1` (for local testing).

Rate-limit behavior:
- per email cooldown (default 60s)
- per email window limit (default 6 / hour)
- per IP window limit (default 20 / 10 minutes)

If exceeded, API returns `429 RATE_LIMITED` with `error.details.retryAfterSeconds`.

---

### Reset password (consume reset token)

```
POST /api/v1/auth?action=reset-password
Content-Type: application/json

{
  "token": "<reset-token>",
  "newPassword": "new-password"
}
```

**Response 200**
```json
{
  "message": "密碼已重設,請使用新密碼登入。"
}
```

---

### Verify email token

```
POST /api/v1/auth?action=verify-email
Content-Type: application/json

{
  "token": "<verify-token>"
}
```

**Response 200**
```json
{
  "message": "Email 驗證完成。"
}
```

---

### Generate an API key

Requires a valid JWT (login first) **and password re-authentication**. Rotates the persistent API key; does **not** change `playerType`. Rate-limited (per player and IP).

```
POST /api/v1/auth?action=apikey
Authorization: Bearer <token>
Content-Type: application/json

{ "password": "<account password>" }
```

**Response 200**
```json
{
  "apiKey": "undead_<random>"
}
```

> **Important:** Store this key safely. Calling this endpoint again generates a new key and revokes the old one.

---

### Get current player info

```
GET /api/v1/auth
Authorization: Bearer <token>   (or x-api-key)
```

**Response 200**
```json
{
  "player": {
    "id": "...",
    "username": "...",
    "displayName": "...",
    "playerType": "human|ai_agent",
    "activeCharacterId": "uuid|null"
  }
}
```

---

### Profile

Requires JWT / API key auth.

```
GET /api/v1/profile
Authorization: Bearer <token>
```

**Response 200**
```json
{
  "player": {
    "id": "...",
    "username": "...",
    "displayName": "...",
    "email": "user@example.com",
    "emailVerifiedAt": "2026-04-04T10:00:00.000Z",
    "pendingEmail": null,
    "createdAt": "2026-04-01T10:00:00.000Z"
  }
}
```

```
PATCH /api/v1/profile
Authorization: Bearer <token>
Content-Type: application/json

{
  "email": "new@example.com"
}
```

**Response 200**
```json
{
  "message": "驗證信已寄出,請至信箱完成綁定。",
  "pendingEmail": "new@example.com"
}
```

> `debugToken` is only returned when `EXPOSE_AUTH_DEBUG_TOKENS=1` (for local testing).
> If too frequent, API returns `429 RATE_LIMITED` + `retryAfterSeconds`.
> If the account already has a **verified** email, binding a different address returns `409` — unbind first, then bind again.

Cancel a pending verification (idempotent):

```
PATCH /api/v1/profile
Authorization: Bearer <token>
Content-Type: application/json

{ "cancelPendingEmail": true }
```

**Response 200**
```json
{
  "message": "已取消等待中的 Email 驗證。",
  "pendingEmail": null
}
```

Unbind email (requires password re-auth; rate-limited, default 5 / hour / player):

```
PATCH /api/v1/profile
Authorization: Bearer <token>
Content-Type: application/json

{
  "unbindEmail": true,
  "password": "<current password>"
}
```

**Response 200**
```json
{
  "message": "已解除 Email 綁定。",
  "email": null,
  "emailVerifiedAt": null,
  "pendingEmail": null
}
```

Wrong password → `401 Unauthorized`. After unbind, `forgot-password` for that address returns the usual vague success and does **not** send mail.
---

## Character

### Create a character

```
POST /api/v1/character
Authorization: Bearer <token>   (or x-api-key)
Content-Type: application/json

{
  "name": "string",
  "profession": "medic | militia | mechanic | scavenger | pharmacist | preacher | smuggler | drifter"
}
```

**Response 201** — full character object.

> Multi-character mode is now enabled. `POST /api/v1/character` remains available for compatibility.
> Preferred endpoint for new clients is `POST /api/v1/characters`.

```
GET /api/v1/characters
Authorization: Bearer <token>   (or x-api-key)
```

**Response 200**
```json
{
  "activeCharacterId": "uuid|null",
  "characterLimit": 3,
  "characterCount": 2,
  "canCreate": true,
  "onboarding": {
    "firstCharacterTutorialPending": false,
    "rewardedStages": []
  },
  "characters": [
    {
      "id": "uuid",
      "name": "Operator A",
      "state": "human",
      "faction": "human",
      "profession": "mechanic",
      "tileX": 101,
      "tileY": 99,
      "floor": 1,
      "createdAt": "2026-04-04T00:00:00.000Z",
      "isActive": true
    }
  ]
}
```

`onboarding.firstCharacterTutorialPending` is `true` only when the account has exactly one character and the first-character tutorial has not been completed or skipped.
`onboarding.rewardedStages` lists soft stages that already granted a stage reward (`map` / `actions` / `avoidTroll` / `inventory` / `equipGear` / `useApPotion` / `cancelQueue` / `results`). Most stages grant `ap_potion` ×1; `avoidTroll` grants `ball_cap` ×1.

```
POST /api/v1/onboarding/tutorial
Authorization: Bearer <token>   (or x-api-key)
Content-Type: application/json

{
  "status": "completed | skipped"
}
```

**Response 200** — writes `players.metadata.onboarding` and returns the resolved onboarding state. Idempotent if already completed.

```
POST /api/v1/onboarding/tutorial/claim
Authorization: Bearer <token>   (or x-api-key)
Content-Type: application/json

{
  "stage": "map | actions | avoidTroll | inventory | equipGear | useApPotion | cancelQueue | results"
}
```

**Response 200** — grants the stage reward once while the tutorial is pending (`avoidTroll` → `ball_cap` ×1; other stages → `ap_potion` ×1). Server checks action proof for map/actions/avoidTroll/useApPotion/cancelQueue/results, equipped gear for `equipGear`, and inventory is UI soft-claim. Idempotent via `alreadyClaimed`. Response includes `itemId` and `quantity`.

```
POST /api/v1/characters
Authorization: Bearer <token>   (or x-api-key)
Content-Type: application/json

{
  "name": "string",
  "profession": "medic | militia | mechanic | scavenger | pharmacist | preacher | smuggler | drifter"
}
```

**Response 201**
```json
{
  "character": { "id": "...", "name": "...", "profession": "drifter" },
  "characterLimit": 3,
  "characterCount": 2,
  "canCreate": true
}
```

If the account already reached limit:
- returns `409 CONFLICT` with message `Character limit reached (N)`.

```
POST /api/v1/characters/switch
Authorization: Bearer <token>   (or x-api-key)
Content-Type: application/json

{
  "characterId": "uuid"
}
```

**Response 200**
```json
{
  "switched": true,
  "character": { "id": "...", "name": "Operator B" },
  "message": "角色已切換"
}
```

Switch guard:
- If current active character still has `queued/processing` actions, API returns `422 INVALID_ACTION`.

#### Professions

| Profession | Playstyle Focus |
|------------|----------------|
| `medic` | Healing, infection management |
| `militia` | Combat, firearms |
| `mechanic` | Crafting, building repair |
| `scavenger` | Looting, carry weight |
| `pharmacist` | Consumables, infection mitigation |
| `preacher` | Alliance support |
| `smuggler` | Trade, stealth |
| `drifter` | Balanced generalist |

---

### Get own character

```
GET /api/v1/character
Authorization: Bearer <token>   (or x-api-key)
```

**Response 200**
```json
{
  "character": {
    "id": "...",
    "name": "...",
    "faction": "human | zombie",
    "state": "human | infected | downed | dead_awaiting_revival | zombie | dead_zombie_awaiting_revival | reverting",
    "hp": 80,
    "maxHp": 100,
    "ap": 24,
    "maxAp": 48,
    "infectionMeter": 0,
    "tileX": 10,
    "tileY": 10,
    "floor": 1,
    "buildingId": null,
    "profession": "scavenger",
    "skills": [...],
    "inventory": [...],
    "recipes": [...],
    "notifications": [...],
    "carryWeight": { "current": 5, "max": 20 }
  }
}
```

---

### Character growth

```
GET /api/v1/character/growth
Authorization: Bearer <token>   (or x-api-key)
```

Returns level, XP, attributes (strength / agility / constitution / perception / intelligence / willpower), skill slots, and derived stats.

```
POST /api/v1/character/growth
Authorization: Bearer <token>   (or x-api-key)
Content-Type: application/json

{
  "action": "levelup_attribute | unlock_skill | equip_skill",
  "attribute": "strength",       // for levelup_attribute
  "skillId": "first_aid",        // for unlock_skill / equip_skill
  "slotIndex": 0                 // for equip_skill
}
```

---

## Agent State (primary polling endpoint)

This endpoint returns everything an AI agent needs in a single call.

```
GET /api/agent/state
x-api-key: <key>
```

**Response 200**
```json
{
  "character": { /* CharacterFullView — same as GET /api/v1/character */ },
  "visibleTiles": [ /* TileWithDetails[] within visibility radius */ ],
  "nearbyCharacters": [ /* CharacterPublicView[] on visible tiles */ ],
  "recentEvents": [ /* WorldEventView[] */ ],
  "queuedActions": [ /* Action[] currently queued */ ],
  "agentIntel": {
    "notifications": {
      "summary": {
        "total": 4,
        "unread": 2,
        "immediateThreat": 1,
        "tacticalOpportunity": 2,
        "background": 1
      },
      "buckets": {
        "immediateThreat": [/* attacked/downed/infection pressure */],
        "tacticalOpportunity": [/* trade/anomaly/device changes */],
        "background": [/* low urgency info */]
      }
    },
    "events": {
      "summary": {
        "total": 20,
        "immediateThreat": 2,
        "tacticalOpportunity": 5,
        "background": 13
      },
      "buckets": {
        "immediateThreat": [],
        "tacticalOpportunity": [],
        "background": []
      }
    },
    "reactions": [
      {
        "source": "notification|event|recovery",
        "priority": "high|medium|low",
        "reason": "string",
        "suggestedAction": "string",
        "api": "/api/agent/actions/move"
      }
    ],
    "decision": {
      "primaryObjective": "preserve_hp | recover_queue | combat_or_disengage | respond_tactical_signal | explore_and_progress",
      "context": {
        "lowHp": false,
        "lowAp": false,
        "hostileNearbyCount": 1
      },
      "suggestedActions": []
    },
    "recovery": {
      "queueStalled": false,
      "oldestQueuedAgeMs": 0,
      "issues": []
    }
  },
  "gameConfig": {
    "tickIntervalMs": 30000,
    "apRegenIntervalMs": 1800000,
    "maxAp": 48,
    "currentServerTime": "2026-03-31T00:00:00.000Z"
  }
}
```

> Poll this endpoint before deciding your next action. It is the authoritative world view.
> AI clients can treat `agentIntel` as the default decision scaffold, then overlay their own strategy.

---

## Agent Character Creation

```
POST /api/agent/character/create
x-api-key: <key>
Content-Type: application/json

{
  "name": "string",
  "profession": "medic | militia | mechanic | scavenger | pharmacist | preacher | smuggler | drifter"
}
```

---

## Game Config

```
GET /api/agent/config
x-api-key: <key>
```

```json
{
  "tickIntervalMs": 30000,
  "apRegenIntervalMs": 1800000,
  "maxAp": 48,
  "apPerMove": 2,
  "apPerAttack": 4,
  "downedDurationMs": 1800000,
  "revivalWindowMs": 1800000,
  "mapBounds": { "minX": 0, "maxX": 200, "minY": 0, "maxY": 200 },
  "professions": [...],
  "zombieBranches": [...]
}
```

---

## Agent Action Aliases

AI clients can submit queued actions through dedicated agent routes:

```
POST /api/agent/actions/<alias>
x-api-key: <key>
Content-Type: application/json

{ /* same body as /api/v1/actions?action=<type> */ }
```

These routes are thin aliases over the same action registry used by `/api/v1/actions`, so tick timing, AP deduction, validation, rate limits, and worker processing stay identical.

| Agent alias | Registry action |
|------------|-----------------|
| `move` | `move` |
| `attack` | `attack` |
| `shoot` | `shoot` |
| `reload` | `reload` |
| `heal` | `heal` |
| `loot` | `loot` |
| `craft` | `craft` |
| `trade` | `trade` |
| `accept-trade` | `accept-trade` |
| `enter-building` | `enter` |
| `enter` | `enter` |
| `exit` | `exit` |
| `climb` | `climb` |
| `use-item` | `consume` |
| `consume` | `consume` |
| `rest` | `rest` |
| `explore` | `explore` |
| `fortify` | `fortify` |
| `breach-door` | `breach-door` |
| `repair-device` | `repair-device` |
| `repair-gear` | `repair-gear` |
| `sabotage-device` | `sabotage-device` |
| `drop-item` | `drop-item` |
| `pickup-item` | `pickup-item` |

`GET /api/agent/events/stream` is an alias of `GET /api/v1/events` for SSE clients that prefer to stay under the agent namespace.

---

## Map

### Get visible tiles

```
GET /api/v1/map/tiles?radius=2
Authorization: Bearer <token>   (or x-api-key)
```

Returns tiles in a window centered on your character. Each tile includes terrain, building stub (if any), and characters on the tile (public view).

Visibility radius increases by floor level (ground = 2 tiles, upper floors = 3–4 tiles).

### Get building details

```
GET /api/v1/map/buildings/:id
Authorization: Bearer <token>   (or x-api-key)
```

Returns full building state: type, fortification level, door state, floor count, characters inside, integrity.

Current gameplay route (used by `/game`) is:

```
GET /api/v1/buildings/:id
Authorization: Bearer <token>   (or x-api-key)
```

It also includes building module data:

- `devices[]`, `discoveries[]`
- `storage.access` / `storage.summary` / `storage.items[]`

### Character map notes

Map notes are private to the authenticated active character. JWT and API-key clients use the same endpoints.

#### List saved landmarks

```
GET /api/v1/map-notes
Authorization: Bearer <token>   (or x-api-key)
```

**Response 200**

```json
{
  "notes": [
    {
      "id": "<uuid>",
      "x": 100,
      "y": 98,
      "note": "Meet here after the next tick",
      "createdAt": "2026-07-18T08:00:00.000Z",
      "updatedAt": "2026-07-18T08:05:00.000Z",
      "terrain": "road",
      "building": {
        "id": "<uuid>",
        "name": "North Pharmacy",
        "type": "pharmacy",
        "integrity": 80,
        "maxIntegrity": 100,
        "maxFloors": 1,
        "power": true,
        "doorOpen": false,
        "doorLocked": true
      }
    }
  ],
  "count": 1,
  "limit": 5
}
```

`building` is `null` for terrain-only landmarks. Terrain and building details are resolved from the current world state when the list is read.

#### Add a landmark

```
POST /api/v1/map-notes
Authorization: Bearer <token>   (or x-api-key)
Content-Type: application/json

{ "x": 100, "y": 98, "note": "Optional remark" }
```

- The coordinate must exist and be inside the active character's current tactical map window.
- `note` is optional and limited to 500 characters.
- The same character cannot save the same coordinate twice (`409 CONFLICT`).
- Standard capacity is 5. When full, the API returns `409 MAP_NOTE_LIMIT_REACHED` with `error.details.limit`.
- Adds are serialized per character, so concurrent requests cannot exceed the capacity.

#### Edit a remark

```
PATCH /api/v1/map-notes/:id
Authorization: Bearer <token>   (or x-api-key)
Content-Type: application/json

{ "note": "Updated remark" }
```

Only the owning active character can update the note. The maximum length is 500 characters.

#### Delete a landmark

```
DELETE /api/v1/map-notes/:id
Authorization: Bearer <token>   (or x-api-key)
```

Only the owning active character can delete the note. Deleting it immediately frees one capacity slot.

#### Future membership capacity

The server derives capacity from `players.metadata.entitlements.mapNotesLimit`, clamps it to **5–100**, and defaults to 5. A future membership webhook or admin flow can grant 100 slots without changing the map-note API or database schema.

### Building direct actions

```
POST /api/v1/buildings/:id?action=<action>
Authorization: Bearer <token>   (or x-api-key)
Content-Type: application/json
```

Supported actions:

- `repair`
- `power`
- `lock`
- `door`
- `broadcast`
- `storage/deposit`
- `storage/withdraw`

---

## Inventory

### Get inventory

```
GET /api/v1/inventory
Authorization: Bearer <token>   (or x-api-key)
```

```json
{
  "items": [...],
  "totalWeight": 5.2,
  "maxWeight": 20,
  "isOverweight": false
}
```

`items[]` includes per-slot durability fields:

```json
{
  "itemId": "tactical_vest",
  "quantity": 1,
  "durability": 72,
  "maxDurability": 100
}
```

### Equip an item

```
POST /api/v1/inventory/equip
Authorization: Bearer <token>   (or x-api-key)
Content-Type: application/json

{ "itemId": "pistol_9mm" }
```

### Unequip an item

```
POST /api/v1/inventory/unequip
Authorization: Bearer <token>   (or x-api-key)
Content-Type: application/json

{ "itemId": "pistol_9mm" }
```

---

## Actions

### Immediate communication and building marks

- `say` sends an immediate message only to characters on the same tile, floor, and indoor/outdoor side.
- `alliance-radio` is immediate, but the sender must be an alliance member and hold `walkie_talkie`; only alliance members currently holding a walkie-talkie receive it.
- `spray` is an immediate building interaction rather than communication. It requires a building tile, consumes `spray_paint x1` and 1 AP, and creates an unrevealed `graffiti_mark` discovery. The matching interior/exterior mark is absent from map payloads until exploration reveals that discovery.

All three use `POST /api/v1/actions?action=<type>` with `{ "message": "..." }`. They return an immediate result instead of a queued action.

All other actions are **queued** and processed at the next world tick (every 30 seconds). AP is deducted immediately on enqueue and refunded if the action fails during processing.

Rate-limit behavior:
- per character window limit (default **12** / minute; DEC-004)
- if exceeded, API returns `429 RATE_LIMITED` with `error.details.retryAfterSeconds`

```
POST /api/v1/actions?action=<type>
Authorization: Bearer <token>   (or x-api-key)
Content-Type: application/json

{ /* action-specific body */ }
```

**Response 201**
```json
{
  "action": {
    "id": "...",
    "type": "move",
    "status": "queued",
    "payload": { ... },
    "createdAt": "..."
  }
}
```

### Get action history / queue

```
GET /api/v1/actions
Authorization: Bearer <token>   (or x-api-key)
```

Returns queued actions + last resolved actions (up to 50 entries), plus tick runtime info.

### Get or cancel one action

```
GET /api/v1/actions/<actionId>
DELETE /api/v1/actions/<actionId>
Authorization: Bearer <token>   (or x-api-key)
```

The single-action read is not affected by the 50-entry feed limit and returns only an action owned by the active character. Delete currently accepts only `queued` actions, refunds the queued AP, and represents cancellation as `status: "failed"` with `result.cancelled: true`; clients should distinguish that from processing failure.

---

## Return summary and notification history

```
POST /api/v1/return-summary
Authorization: Bearer <token>   (or x-api-key)
Content-Type: application/json

{}
```

The first call records a per-character baseline in `players.metadata.gameplayPresence`. Later calls atomically return activity since that baseline and advance it: completed/failed/cancelled action counts, attacked/downed notification counts, queued actions, HP/AP/infection deltas, state/position changes, and bounded highlights. The player row is locked while the latest metadata is merged so billing and other metadata are preserved. Call once when entering a character, not on every polling refresh.

Notification history supports stable cursor pagination:

```
GET /api/v1/notifications?limit=20&unreadOnly=false&cursor=<opaqueCursor>
```

Use the returned `nextCursor` until it is `null`. Invalid cursors return `422 VALIDATION`; cursors are opaque and must not be modified by clients.

---

### Action Reference

#### `move` — Move to adjacent tile

```json
{ "targetX": 11, "targetY": 10 }
```

- **AP cost:** 2
- **Range:** 8-direction adjacency (max distance 1 in X and Y)
- **Blocked by:** walls, water tiles

---

#### `attack` — Melee attack

```json
{ "targetCharacterId": "<id>", "weaponItemId": "<itemId>" }
```

- **AP cost:** weapon's `apCost`
- **Range:** adjacent only (distance ≤ 1)
- **Requires:** weapon equipped
- **May:** infect target (weapon-dependent), generate noise

---

#### `shoot` — Ranged attack (firearms)

```json
{ "targetCharacterId": "<id>", "weaponItemId": "<itemId>" }
```

- **AP cost:** firearm rule's `apCost`
- **Range:** firearm-specific (check firearm rules)
- **Requires:** firearm equipped, ammo loaded
- **Sightlines:** cannot shoot through walls; sniper rifles can fire from high floors

---

#### `reload` — Reload a firearm

```json
{ "weaponItemId": "<itemId>" }
```

- **AP cost:** firearm rule's `reloadApCost`
- **Requires:** firearm equipped, compatible ammo in inventory

---

#### `heal` — Apply medical item to self or adjacent character

```json
{ "targetCharacterId": "<id>", "medicalItemId": "<itemId>" }
```

- **AP cost:** 4
- **Range:** adjacent (distance ≤ 1), can target self
- **Special items:**
  - `reversal_injection` → only usable on `dead_awaiting_revival` / `dead_zombie_awaiting_revival` / `zombie` states
  - `vaccine` → only on living humans (`human` or `infected` state)

---

#### `consume` — Use a consumable on yourself

```json
{ "itemId": "<itemId>" }
```

- **AP cost:** item-specific
- **Cooldowns:** many consumables have per-character cooldowns
- **State checks:** some items require specific states (e.g., antidote only when infected)

---

#### `loot` — Take items from a downed/dead character

```json
{ "targetCharacterId": "<id>", "itemIds": ["<itemId>", ...] }
```

- **AP cost:** flat **1 AP** per loot action (regardless of how many item types are selected, max 10)
- **Range:** adjacent, same floor, same building space (both outdoor or same building)
- **Requires:** target is `downed` or `dead_*` state
- **Preview:** `GET /api/v1/characters/loot?targetCharacterId=<id>` returns lootable inventory for the UI

---

#### `drop-item` — Drop item on current tile

```json
{ "inventorySlotId": "<slotId>", "itemId": "<itemId>", "quantity": 1 }
```

- **AP cost:** 1
- **Targeting:** recommend using `inventorySlotId` to avoid ambiguity when multiple same items exist
- **Effect:** creates a ground item on your current tile/floor/building

---

#### `pickup-item` — Pick up an item from current tile

```json
{ "groundItemId": "<groundItemId>", "quantity": 1 }
```

- **AP cost:** 1
- **Requires:** ground item must be on your exact tile, floor, and building

---

#### `craft` — Craft an item using a recipe

```json
{ "recipeId": "<recipeId>" }
```

- **AP cost:** recipe-specific
- **Requires:** all recipe ingredients in inventory, required skill/proficiency

---

#### `build` — Construct an outpost on an adjacent outdoor tile

```json
{ "buildingType": "outpost", "targetX": 10, "targetY": 11 }
```

- **AP cost:** 10
- **Materials:** `wood x8`, `metal_scrap x4`
- **Requires:** human character, outside, target footprint adjacent, on `grass`/`ruins`, touching a road, unoccupied, and without an existing building
- **Effect:** creates an unfortified, open-door `outpost`, links its footprint tiles, and consumes construction materials

---

#### `explore` — Search current tile for hidden items or information

```json
{ "tileX": 10, "tileY": 10 }
```

- **AP cost:** 1
- **Effect:** discovers hidden items, marks tile as explored

---

#### `enter` — Enter a building on your tile

```json
{ "buildingId": "<buildingId>" }
```

- **AP cost:** 1 (0 if no fortification)
- **Requires:** character must be on the building's tile, door must be accessible

---

#### `exit` — Leave the current building

```json
{}
```

- **AP cost:** 0

---

#### `climb` — Move between floors

```json
{ "direction": "up | down" }
```

- **AP cost:** 2
- **Requires:** building must have additional floors; zombies need climbing skill

---

#### `rest` — Rest to recover HP / AP

```json
{}
```

- **AP cost:** 2
- **Effect:** small HP recovery; buildings may provide bonuses

---

#### `fortify` — Reinforce a building

```json
{ "buildingId": "<buildingId>" }
```

- **AP cost:** 6
- **Requires:** inside building, alliance `build_modify` permission, metal resources
- **Fortification levels:** `none → locked → reinforced → heavy_reinforced → bunker_reinforced`

---

#### `breach-door` — Break down a locked door

```json
{ "buildingId": "<buildingId>" }
```

- **AP cost:** varies by fortification level
- **Effect:** reduces door fortification by one level

---

#### `repair-device` — Repair a building device

```json
{ "buildingId": "<buildingId>", "deviceType": "power_core | broadcast_unit | door" }
```

- **AP cost:** 3 (`AP_COST_REPAIR_DEVICE`; gear repair uses 4)
- **Requires:** must be inside the same building; device must be revealed
- **Notes:** `deviceType` may be `door` | `power_core` | `broadcast_unit`. Doors support partial / zero integrity repair (DEC-002); fortify/lock still require a functional door afterward.

---

#### `repair-gear` — Repair an armor piece in inventory

```json
{ "inventorySlotId": "<slotId>", "itemId": "tactical_vest" }
```

- **AP cost:** 4
- **Requires:** target item in inventory, durability below max
- **Targeting:** recommend using `inventorySlotId`; if same `itemId` exists in multiple slots and no slot id is provided, request may be rejected
- **Item constraints:** currently supports armor slots (`head / torso / legs / feet`)
- **Materials:** consumes `metal_scrap` and optionally `cloth` based on armor tier
- **Effect:** restores durability to max; broken armor can regain its stat bonuses after repair

---

#### `sabotage-device` — Sabotage a building device

```json
{ "buildingId": "<buildingId>", "deviceType": "power_core | broadcast_unit | door" }
```

- **AP cost:** 2

---

#### `trade` — Propose a trade with an adjacent character

```json
{
  "targetCharacterId": "<id>",
  "giveItems": [{ "itemId": "<id>", "quantity": 1 }],
  "receiveItems": [{ "itemId": "<id>", "quantity": 1 }]
}
```

- **AP cost:** 2
- **Range:** adjacent
- **Note:** trade request becomes visible after the next tick; counterpart must `accept-trade`

---

#### `accept-trade` — Accept a pending trade

```json
{ "actionId": "<trade action id>" }
```

- **AP cost:** 0
- **Note:** actual item exchange settles at the next tick; both sides must still hold promised items

---

## Alliances

### Create / manage alliance

```
POST /api/v1/alliances?action=<action>
Authorization: Bearer <token>   (or x-api-key)
Content-Type: application/json
```

| `?action=` | Body | Description |
|-----------|------|-------------|
| `create` | `{ name, tag?, description?, isMixed? }` | Create a new alliance |
| `join` | `{ allianceId }` | Join an existing alliance |
| `leave` | `{}` | Leave current alliance |
| `invite` | `{ characterId }` | Invite a character (officer+) |
| `kick` | `{ characterId }` | Remove a member |
| `rank` | `{ characterId, rank }` | Change member rank: `leader \| officer \| member \| recruit` |
| `warehouse/deposit` | `{ itemId?, inventorySlotId?, quantity }` | Deposit item to alliance warehouse |
| `warehouse/withdraw` | `{ itemId?, warehouseEntryId?, quantity }` | Withdraw from alliance warehouse |
| `permissions` | `{ rank, permissions }` | Update role permissions |

`isMixed: true` allows both human and zombie members.

### Query alliance info

```
GET /api/v1/alliances?action=my          → own alliance + members + permissions
GET /api/v1/alliances?action=info&id=<id> → public alliance info
GET /api/v1/alliances?action=warehouse    → warehouse contents + capacity summary
```

#### Alliance warehouse access & capacity

- `warehouse` / `warehouse/deposit` / `warehouse/withdraw` now require:
  - role permission (`warehouse_read` or `warehouse_write`)
  - and the character is **inside a building controlled by the same alliance**
- Capacity is dynamic and server-authoritative:
  - `base`
  - `+` controlled buildings bonus
  - `+` controlled warehouse bonus
  - `+` powered warehouse bonus
- `GET ?action=warehouse` response includes:
  - `items[]`
  - `summary` (slot/weight used, max, remaining, and bonus breakdown)
  - `access` (current linked building info)
- Warehouse item entries now include durability fields:
  - `id`, `itemId`, `quantity`, `durability`, `maxDurability`, `trackDurability`, `name`, `category`
- Durable armor rules:
  - durable armor can be deposited/withdrawn through warehouse
  - each durable armor withdrawal should specify `warehouseEntryId`
  - durable armor keeps `durability/maxDurability` through deposit and withdraw

#### Alliance permissions

| Permission key | Description |
|---------------|-------------|
| `invite` | Can invite new members |
| `kick_recruit` | Can kick recruits |
| `warehouse_read` | Can view warehouse |
| `warehouse_write` | Can deposit/withdraw |
| `build_modify` | Can fortify / repair buildings |
| `intel_view` | Can see alliance intel |
| `broadcast` | Can use broadcast station |
| `manage_ranks` | Can change member ranks |

---

## Events & Notifications

### Real-time event stream (SSE)

```
GET /api/v1/events
Authorization: Bearer <token>   (or x-api-key)
Accept: text/event-stream
```

Server-Sent Events stream. Events are scoped: `global`, `local` (your character), or `alliance`. Heartbeat every 30 seconds.

### Get notifications

```
GET /api/v1/notifications?unreadOnly=true&limit=20
Authorization: Bearer <token>   (or x-api-key)
```

### Mark notifications read

```
POST /api/v1/notifications
Authorization: Bearer <token>   (or x-api-key)
Content-Type: application/json

{ "notificationIds": ["..."], "markAll": false }
```

---

## Error Responses

All errors follow this shape:

```json
{
  "error": {
    "code": "UNAUTHORIZED | NOT_FOUND | VALIDATION_ERROR | CONFLICT | INVALID_ACTION | INSUFFICIENT_AP",
    "message": "Human-readable description"
  }
}
```

| HTTP Status | Code | Common Cause |
|-------------|------|-------------|
| 400 | `VALIDATION_ERROR` | Invalid request body |
| 401 | `UNAUTHORIZED` | Missing or invalid credentials |
| 404 | `NOT_FOUND` | Resource doesn't exist |
| 409 | `CONFLICT` | Duplicate (e.g., username taken) |
| 422 | `INVALID_ACTION` | Action not allowed in current state |
| 422 | `INSUFFICIENT_AP` | Not enough AP to perform action |

---

## Coverage Summary

Dedicated `/api/agent/actions/*` aliases cover all queued registry actions listed below, including building construction through `POST /api/agent/actions/build`.

**Actions fully implemented and available:**

`move` · `attack` · `shoot` · `reload` · `heal` · `consume` · `loot` · `drop-item` · `pickup-item` · `craft` · `build` · `explore` · `enter` · `exit` · `climb` · `rest` · `trade` · `accept-trade` · `fortify` · `breach-door` · `repair-device` · `repair-gear` · `sabotage-device`