# DDO Tracker — Plugin API

Base URL: `https://ddotracker.zepsu.com/api/plugin`

Auth: send `Authorization: Bearer <token>` on every request except login and docs.  
Content-Type: `application/json`  
CORS: allowed for all origins on `/api/plugin/*`  
**API version:** `6` (see `GET /info`)

### Discovery (standard)

| URL | Format | Use |
|-----|--------|-----|
| [`/api`](https://ddotracker.zepsu.com/api) | HTML + Swagger UI | Human docs in the site menu |
| [`/api/openapi.json`](https://ddotracker.zepsu.com/api/openapi.json) | OpenAPI 3.0 JSON | Postman, Insomnia, codegen, SDK tools |
| [`/api/plugin/openapi.json`](https://ddotracker.zepsu.com/api/plugin/openapi.json) | OpenAPI 3.0 JSON | Same spec (plugin namespace alias) |
| [`/api/plugin/docs`](https://ddotracker.zepsu.com/api/plugin/docs) | Markdown | Long-form reference |
| [`/openapi.json`](https://ddotracker.zepsu.com/openapi.json) | OpenAPI 3.0 JSON | Root alias |

Live markdown: [`GET /api/plugin`](https://ddotracker.zepsu.com/api/plugin) or [`GET /api/plugin/docs`](https://ddotracker.zepsu.com/api/plugin/docs)

---

## Concepts

| Term | Values | Meaning |
|------|--------|---------|
| **Remake / content tier** (`difficulty`) | `Heroic` \| `Epic` \| `Legendary` \| `Unknown` | Which version of the quest. Use `Unknown` when the companion cannot tell. |
| **Challenge setting** (`setting`) | `casual` \| `normal` \| `hard` \| `elite` \| `reaper` \| `unknown` | How the run was played. Use `unknown` when the SDK cannot detect challenge. |
| **Play on** (`targetDifficulty`) | `any` \| `casual` \| `normal` \| `hard` \| `elite` \| `reaper` | Website filter / default for manual toggles. Reaper uses Elite effective-level math for Full XP. |

### Quest remakes (important)

Many quests exist as **separate catalog rows** that share the same `name` but differ by `difficulty` and `level`. Example:

| name | difficulty | level |
|------|------------|------:|
| Haywire Foundry | Heroic | 9 |
| Haywire Foundry | Epic | 22 |

- Completions are keyed by `(name, difficulty, setting)` — Heroic and Epic are **independent**.
- The catalog **never collapses** remakes. `/quests`, `/quests/search`, and `/quests/lookup` all return every matching tier.
- When auto-completing a finish, resolve the remake with `/quests/search` (pass character `level`) or `/quests/lookup`, then POST that exact `difficulty` when known.
- If remake or challenge cannot be determined, still POST the completion with `difficulty: "Unknown"` and/or `setting: "unknown"`. **Do not skip the run** — unknown values are accepted and stored. The website treats an `Unknown` remake as matching any remake of that quest name.

### Completions & Play on

- You may log **multiple** challenge settings for the same quest remake. Favor counts only the **best** setting once.
- Website list filters: a remake counts **complete** if **any** challenge setting is logged.
- `targetDifficulty: "any"` (default) means: list treats remakes as done for any logged setting; it is **not** a Play-on value for completions. Prefer a concrete challenge when known; otherwise POST `setting: "unknown"`.
- Live updates on the website (optional toggle) poll progress so plugin completions appear in an open browser.

### Version availability filter (`versionFilter`)

Per-tier flags on progress / character: `any` \| `yes` \| `no`.

| Flag | Meaning |
|------|---------|
| `yes` | Quest family must **include** that remake tier |
| `no` | **Hide** that remake’s rows (other tiers of the same quest still show) |
| `any` | No constraint for that tier |

Example: `{ "heroic": "no", "epic": "yes", "legendary": "any" }` keeps families that have an Epic remake and hides Heroic rows (Epic Haywire L22 shows; Heroic L9 does not).

---

## Auth

Accounts can use a **username**, an **email**, or both. Usernames are shown on shared routes; emails are private to the account holder.

### `POST /auth/login`

Get a long-lived API token (store it in the plugin).

Login accepts **username + password** or **email + password** (same accounts as the website).

```json
{ "identifier": "playername", "password": "…", "label": "dungeonhelper" }
```

```json
{ "identifier": "player@example.com", "password": "…", "label": "dungeonhelper" }
```

| Field | Required | Notes |
|-------|----------|--------|
| `identifier` | yes* | Username **or** email |
| `email` | yes* | Alias for `identifier` (legacy) |
| `username` | yes* | Alias for `identifier` |
| `login` | yes* | Alias for `identifier` |
| `password` | yes | Account password |
| `label` | no | Token label (default `plugin`) |

\* Provide one of `identifier` / `email` / `username` / `login`.

Response:

```json
{
  "token": "ddot_…",
  "tokenId": 1,
  "label": "dungeonhelper",
  "user": {
    "id": 1,
    "email": "player@example.com",
    "username": "playername",
    "displayName": "playername"
  }
}
```

`email` may be `null` for username-only accounts. `displayName` is the public label (`username` or `"Player"`).

### `GET /auth/me`

Requires bearer. Returns `{ "id", "email", "username", "displayName" }` for the token owner.

### `POST /auth/logout`

Revokes the current bearer token. Returns `{ "ok": true }`.

---

## Characters (profile)

Profile fields follow the DDO Character Sheet ([wiki](https://ddowiki.com/page/Character_sheet_(stats))).

### Character object (response shape)

```json
{
  "id": 16,
  "name": "Brunhilde Ironfist",
  "givenName": "Brunhilde",
  "firstName": "Brunhilde",
  "surname": "Ironfist",
  "race": "Dwarf",
  "gender": "Female",
  "alignment": "Lawful Good",
  "server": "Orien",
  "guild": "Hammer of the Gods",
  "classes": [
    { "name": "Barbarian", "levels": 1 },
    { "name": "Cleric", "levels": 5 }
  ],
  "classSummary": "1 Barbarian / 5 Cleric",
  "epicLevels": 0,
  "heroicLevel": 6,
  "totalLevel": 6,
  "characterLevel": 6,
  "minLevel": 6,
  "lastLoginAt": "2026-07-14T12:03:56.000Z",
  "targetDifficulty": "any",
  "levelBand": 0,
  "excludedPatrons": [],
  "versionFilter": { "heroic": "any", "epic": "any", "legendary": "any" },
  "xpBoosts": {},
  "optionalPursuit": {},
  "shareToken": "…",
  "shareUrl": "/s/…",
  "createdAt": "…",
  "updatedAt": "…"
}
```

| Field | Notes |
|-------|--------|
| `targetDifficulty` | Play on: `any` (default) \| `casual` \| `normal` \| `hard` \| `elite` \| `reaper` |
| `levelBand` | `0` = Full XP filter; `255` = Any level |
| `versionFilter` | Per tier `any` \| `yes` \| `no` (see Concepts) |
| `xpBoosts` / `optionalPursuit` | Same shapes as website tracker |

**Level rules**
- Up to **3** classes, each **1–20** levels  
- `epicLevels`: 0–30  
- `totalLevel` / `characterLevel` = sum(class levels) + epic levels (capped at 34)

### `GET /characters/meta`

Picklists for UI / validation:

```json
{
  "classes": ["Alchemist", "Artificer", "Barbarian", "…"],
  "races": ["Aasimar", "Dwarf", "Human", "…"],
  "alignments": ["Lawful Good", "…"],
  "genders": ["Male", "Female"],
  "servers": ["Orien", "Thelanis", "…"],
  "maxMulticlass": 3,
  "maxClassLevel": 20,
  "maxEpicLevels": 30,
  "maxTotalLevel": 34
}
```

### `GET /characters`

List all characters for the logged-in user (full profile objects).

### `GET /characters/find`

Match an in-game toon to a tracker character.

Query params (any combination):

| Param | Alias | Notes |
|-------|--------|--------|
| `givenName` | `firstName` | Case-insensitive |
| `surname` | `lastName` | Case-insensitive |
| `name` | | Matches display name or given name if first/last omitted |
| `server` | | Case-insensitive |

Example: `GET /characters/find?givenName=Brunhilde&surname=Ironfist&server=Orien`

### `POST /characters`

Create a character with full profile.

```json
{
  "givenName": "Brunhilde",
  "surname": "Ironfist",
  "race": "Dwarf",
  "gender": "Female",
  "alignment": "Lawful Good",
  "server": "Orien",
  "guild": "Hammer of the Gods",
  "classes": [
    { "name": "Barbarian", "levels": 1 },
    { "name": "Cleric", "levels": 5 }
  ],
  "epicLevels": 0,
  "lastLoginAt": "now"
}
```

Aliases accepted: `firstName`, `lastName`, `classLevels`, `epic_levels`, `last_login_at`.  
Legacy: `{ "name": "Brunhilde Ironfist" }` still works (splits on first space).

Returns **201** + character object.

### `GET /characters/:id`

Full profile for one character.

### `PATCH /characters/:id` / `PUT /characters/:id`

Update profile fields (partial). Omitting a field keeps the previous value.

```json
{
  "classes": [
    { "name": "Barbarian", "levels": 1 },
    { "name": "Cleric", "levels": 5 }
  ],
  "epicLevels": 2,
  "race": "Dwarf",
  "server": "Orien",
  "touchLastLogin": true
}
```

- `PUT` is the same as `PATCH` for the plugin (sync push).  
- `touchLastLogin: true` or `seen: true` sets `lastLoginAt` to now (unless you send an explicit timestamp).

### `POST /characters/:id/login`

Heartbeat when the player logs into that toon.

```json
{ "lastLoginAt": "now" }
```

Or omit body / send ISO timestamp.

---

## Progress & completions

### `GET /characters/:id/progress`

Returns the character plus all tracker state (same payload the website uses):

```json
{
  "character": { "id": 16, "name": "…", "targetDifficulty": "any" },
  "minLevel": 20,
  "characterLevel": 20,
  "targetDifficulty": "any",
  "levelBand": 0,
  "excludedPatrons": [],
  "versionFilter": { "heroic": "any", "epic": "any", "legendary": "any" },
  "xpBoosts": {},
  "optionalPursuit": {},
  "completions": [
    {
      "name": "A Break in the Ice",
      "difficulty": "Heroic",
      "setting": "elite",
      "completedAt": "2026-07-14T11:53:32.000Z",
      "durationSeconds": 120,
      "xpEarned": 15420,
      "reaperXpEarned": null
    }
  ],
  "completedQuests": ["A Break in the Ice"],
  "packState": {},
  "questNotes": {}
}
```

### `PUT /characters/:id/progress`

Update filters / boosts / packs / notes / completions. **Partial** — only send fields you change. Omitting `completions` leaves existing completions unchanged.

```json
{
  "targetDifficulty": "any",
  "levelBand": 0,
  "characterLevel": 20,
  "versionFilter": { "heroic": "yes", "epic": "no", "legendary": "any" },
  "xpBoosts": { "vip": true },
  "optionalPursuit": {}
}
```

`targetDifficulty` accepts `any` \| `casual` \| `normal` \| `hard` \| `elite` \| `reaper`.

Response: same shape as `GET …/progress`.

### `POST /characters/:id/completions`

Upsert one run (plugin / Dungeon Helper finish):

```json
{
  "name": "Haywire Foundry",
  "difficulty": "Heroic",
  "setting": "reaper",
  "durationSeconds": 600,
  "xpEarned": 12840,
  "reaperXpEarned": 1840,
  "completedAt": "2026-07-14T12:00:00.000Z"
}
```

| Field | Required | Notes |
|-------|----------|--------|
| `name` | yes | Exact catalog quest name |
| `difficulty` | no | `Heroic` \| `Epic` \| `Legendary` \| `Unknown`. Prefer the remake that was run; send `Unknown` (or omit / send junk) when unsure — **never skip the POST** |
| `setting` | no | `casual` \| `normal` \| `hard` \| `elite` \| `reaper` \| `unknown`. Defaults to `elite` when omitted. Explicit `Unknown` / unrecognized → stored as `unknown`. Aliases: `challengeSetting`, `challenge_setting` |
| `durationSeconds` | no | Run length |
| `xpEarned` | no | **Actual XP awarded in-game** (preferred for stats). Aliases: `xp`, `xpReceived`, `xp_received`, `actualXp`, `actual_xp`, `xp_earned` |
| `reaperXpEarned` | no | **Reaper experience (RXP)** from a Reaper-difficulty run ([wiki](https://ddowiki.com/page/Reaper_difficulty)). Aliases: `reaperXp`, `reaper_xp`, `reaper_xp_earned`, `rxp`, `rxpEarned`, `rxp_earned`. Shown on completed quests when present |
| `completedAt` | no | ISO timestamp (default now) |

When `xpEarned` is present it is stored and used for “XP earned” totals. When omitted, the website **falls back** to catalog XP estimates (base + optionals + boosts). Omitting XP on a later upsert does **not** clear a previously stored actual value.

When `reaperXpEarned` is present it is stored and shown as **RXP** on the completed quest card. Omitting RXP on a later upsert does **not** clear a previously stored value. RXP is only awarded in-game on Reaper (and only when enough enemies were killed); send it whenever the companion can read it from the experience log.

If the companion only knows the challenge (Elite/Reaper/…) and not the remake tier, it may send that value in `difficulty` with `setting` omitted — the API remaps it to `setting` and stores `difficulty: "Unknown"`.

**Do not** send `setting: "any"` as a Play-on filter value; if sent it is stored as `unknown`.  
**201** if new row, **200** if updated. Body: `{ "ok": true, "completion": { …, "xpEarned": 12840, "reaperXpEarned": 1840 } }`.

Website “Live updates” (when enabled) will pick this up within a few seconds.

### `DELETE /characters/:id/completions`

Remove one challenge, or all challenges for a remake.

```json
{ "name": "Haywire Foundry", "difficulty": "Heroic", "setting": "hard" }
```

Clear every challenge for that remake (matches website undo under Play on Any):

```json
{ "name": "Haywire Foundry", "difficulty": "Heroic", "allSettings": true }
```

Or `setting: "all"` \| `"any"` \| `"*"`.

Default when `setting` omitted: remove **elite** only (backward compatible).

---

## Routes & navigation

### `GET /routes`

List shared routes (includes `questCount`, boosts, optionals, and `ownerDisplayName` — never owner email).

### `GET /routes/:id`

Route detail with ordered quests (each step has a concrete `setting`):

```json
{
  "id": 6,
  "name": "Favor rush",
  "ownerDisplayName": "playername",
  "quests": [
    { "position": 0, "name": "…", "difficulty": "Heroic", "setting": "elite" }
  ]
}
```

### `GET /navigation?characterId=16&routeId=6`

Planned route vs that character’s completions.

Optional `match=setting` (default) or `match=remake`:

- **`setting`** — step `completed` only if that exact challenge was logged  
- **`remake`** — step `completed` if **any** challenge was logged for that quest remake (Play on Any)

```json
{
  "character": { "id": 16, "name": "…", "targetDifficulty": "any" },
  "route": { "id": 6, "name": "…" },
  "match": "setting",
  "steps": [
    {
      "position": 0,
      "name": "…",
      "difficulty": "Heroic",
      "setting": "elite",
      "completed": false,
      "settingCompleted": false,
      "remakeCompleted": true,
      "settingsLogged": ["hard"],
      "completedAt": null,
      "durationSeconds": null
    }
  ],
  "nextQuest": { "position": 0, "name": "…", "difficulty": "Heroic", "setting": "elite", "completed": false },
  "progress": { "completed": 0, "total": 3, "remaining": 3, "finished": false }
}
```

Use `nextQuest` for “what to run next”. Prefer `match=remake` when the character’s Play on is `any`.

---

## Catalog / system

| Method | Path | Notes |
|--------|------|--------|
| GET | `/` or `/docs` | This document (markdown) |
| GET | `/quests` | Full catalog — **one object per remake row** |
| GET | `/quests/search?q=…` | Autocomplete — all matching remakes (never collapses) |
| GET | `/quests/lookup?name=…&difficulty=Heroic` | Exact name lookup + `remakes` list |
| GET | `/scorecards` | Scorecard thresholds |
| GET | `/info` | Version + feature flags |

Synced daily from the wiki [Quests by level and XP](https://ddowiki.com/page/Quests_by_level_and_XP). Catalog size is exposed as `questCatalogCount` on `/info`.

### `GET /quests`

```json
{
  "count": 835,
  "quests": [
    {
      "name": "Haywire Foundry",
      "difficulty": "Heroic",
      "level": 9,
      "soloXP": 2834,
      "normalXP": 4996,
      "hardXP": 5268,
      "eliteXP": 5540,
      "duration": "Very long",
      "pack": "Vault of Night",
      "patron": "House Kundarak",
      "favor": 6
    },
    {
      "name": "Haywire Foundry",
      "difficulty": "Epic",
      "level": 22,
      "soloXP": 14124,
      "normalXP": 24316,
      "hardXP": 25091,
      "eliteXP": 25866,
      "duration": "Very long",
      "pack": "Vault of Night",
      "patron": "House Kundarak",
      "favor": 6
    }
  ]
}
```

### `GET /quests/search`

Fuzzy autocomplete for plugin / companion UIs. **Each remake is its own result.**

Requires bearer auth (same as other catalog routes after login).

| Param | Required | Notes |
|-------|----------|--------|
| `q` | yes | Substring match on name (also pack/patron). Aliases: `query`, `name` |
| `difficulty` | no | `Heroic` \| `Epic` \| `Legendary` |
| `level` | no | Character level — ranks enterable / closest remakes first. Aliases: `characterLevel`, `cl` |
| `limit` | no | 1–50 (default 20) |

Example: `GET /quests/search?q=haywire&level=10`

```json
{
  "query": "haywire",
  "count": 2,
  "totalMatches": 2,
  "level": 10,
  "quests": [
    {
      "name": "Haywire Foundry",
      "difficulty": "Heroic",
      "level": 9,
      "soloXP": 2834,
      "normalXP": 4996,
      "hardXP": 5268,
      "eliteXP": 5540,
      "duration": "Very long",
      "pack": "Vault of Night",
      "patron": "House Kundarak",
      "favor": 6,
      "score": 130,
      "enterable": true
    },
    {
      "name": "Haywire Foundry",
      "difficulty": "Epic",
      "level": 22,
      "soloXP": 14124,
      "normalXP": 24316,
      "hardXP": 25091,
      "eliteXP": 25866,
      "duration": "Very long",
      "pack": "Vault of Night",
      "patron": "House Kundarak",
      "favor": 6,
      "score": 40,
      "enterable": false
    }
  ]
}
```

Ranking notes when `level` is provided:

- Exact / prefix / substring name matches score highest (pack/patron weaker).
- Enterable remakes (`characterLevel >= quest.level`) rank above ones the toon cannot enter yet.
- Mild preference: CL &lt; 20 → Heroic; 20–29 → Epic; 30+ → Legendary.

Use `difficulty` (and catalog `level`) from the **chosen** row when posting completions.

### `GET /quests/lookup`

Exact name match (case-insensitive).

| Param | Required | Notes |
|-------|----------|--------|
| `name` | yes | Exact catalog quest name |
| `difficulty` | no | If set, filter to that remake only |

When `difficulty` is omitted, `quests` contains every remake for that name. Response always includes `remakes` (compact tier list) and `remakeCount`.

Example: `GET /quests/lookup?name=Haywire%20Foundry`

```json
{
  "quests": [
    { "name": "Haywire Foundry", "difficulty": "Heroic", "level": 9, "pack": "Vault of Night", "patron": "House Kundarak", "favor": 6 },
    { "name": "Haywire Foundry", "difficulty": "Epic", "level": 22, "pack": "Vault of Night", "patron": "House Kundarak", "favor": 6 }
  ],
  "remakes": [
    { "name": "Haywire Foundry", "difficulty": "Heroic", "level": 9, "pack": "Vault of Night", "patron": "House Kundarak" },
    { "name": "Haywire Foundry", "difficulty": "Epic", "level": 22, "pack": "Vault of Night", "patron": "House Kundarak" }
  ],
  "remakeCount": 2
}
```

Example: `GET /quests/lookup?name=Haywire%20Foundry&difficulty=Epic` → `quests` has only the Epic row; `remakes` still lists both tiers.

**404** if no name match (or no match for the requested difficulty).

### `GET /scorecards`

Wiki scorecard thresholds keyed by quest name (optional pursuit bonuses).

### `GET /info`

```json
{
  "user": { "id": 1, "email": "…", "username": "playername", "displayName": "playername" },
  "characterCount": 3,
  "routeCount": 12,
  "questCatalogCount": 835,
  "apiVersion": 6,
  "docs": "/api/plugin/docs",
  "openapi": "/api/openapi.json",
  "humanDocs": "/api",
  "features": {
    "playOnAny": true,
    "challengeSettings": ["casual", "normal", "hard", "elite", "reaper", "unknown"],
    "remakeTiers": ["Heroic", "Epic", "Legendary", "Unknown"],
    "unknownDifficultyAccepted": true,
    "questSearch": true,
    "questLookupRemakes": true,
    "completionXp": true,
    "completionReaperXp": true,
    "characterProfiles": true,
    "progressPut": true,
    "clearAllSettings": true,
    "navigationMatchRemake": true,
    "liveUpdatesWebsite": true,
    "characterBuilder": true,
    "buildSync": true,
    "liveSnapshot": true,
    "livePromoteToPlanned": true
  }
}
```

| Feature flag | Meaning |
|--------------|---------|
| `questSearch` | `/quests/search` autocomplete is available |
| `questLookupRemakes` | `/quests/lookup` includes `remakes` / `remakeCount` |
| `completionXp` | Completions accept/store `xpEarned` (actual XP); stats fall back to catalog estimates |
| `completionReaperXp` | Completions accept/store `reaperXpEarned` (RXP); shown on completed quests when present |
| `unknownDifficultyAccepted` | Completions accept `Unknown` remake / `unknown` setting; bad values are stored, not rejected |
| `navigationMatchRemake` | `/navigation?match=remake` supported |
| `clearAllSettings` | `DELETE …/completions` with `allSettings` / `setting=all` |
| `characterBuilder` / `buildSync` | Planned build document endpoints (`/build*`) |
| `liveSnapshot` | Live in-game sheet endpoints (`/live*`) — companion “what you are” |
| `livePromoteToPlanned` | `POST …/live/promote-to-planned` copies live → planned builder |

Unauthenticated health (outside plugin namespace): `GET https://ddotracker.zepsu.com/api/health` → `{ "ok": true }`.

---

## Live vs planned (important)

Two separate documents share the same **build document schema**:

| Role | Endpoints | Meaning |
|------|-----------|---------|
| **Live** | `/characters/:id/live*` | What the character **is right now** in-game (companion import). Syncs the quest-tracker profile (classes, level, race, past lives). |
| **Planned** | `/characters/:id/build*` | What you **intend to be** in the website character builder (level 34 plan while playing at level 1). Does **not** overwrite the quest profile once a live snapshot exists. |

You can plan a level-34 build in the builder while the companion keeps writing your current level-1 (or level-20) live sheet. Use `POST …/live/promote-to-planned` when you want the builder to start from the imported live sheet.

---

## Character builder document shape

Canonical JSON for both **live** and **planned**. Companion apps should map into this schema.

```json
{
  "schemaVersion": 1,
  "role": "live",
  "meta": {
    "source": "companion",
    "capturedAt": "2026-07-17T22:00:00.000Z",
    "gameCharacterId": null,
    "incomplete": ["enhancements", "spells"]
  },
  "life": {
    "name": "Ying Monk",
    "race": "Aasimar",
    "alignment": "Lawful Neutral",
    "specialFeats": [
      { "name": "Past Life: Monk", "type": "HeroicPastLife", "stacks": 3 }
    ]
  },
  "tomes": {
    "str": 8, "dex": 8, "con": 8, "int": 8, "wis": 8, "cha": 8,
    "skills": { "Concentration": 0 }
  },
  "build": {
    "level": 20,
    "classes": ["Monk", null, null],
    "abilitySpend": {
      "available": 36,
      "str": 1, "dex": 6, "con": 9, "int": 0, "wis": 10, "cha": 0
    },
    "levelAbilityBumps": { "4": "Wisdom", "8": "Wisdom" },
    "levels": [
      {
        "class": "Monk",
        "feats": [{ "name": "Dodge", "type": "Standard" }],
        "skills": ["Concentration"]
      }
    ],
    "enhancementTrees": {
      "selected": ["Aasimar", "Henshin Mystic"],
      "tier5": "Shintao",
      "spend": [
        {
          "treeName": "Shintao",
          "treeVersion": 2,
          "trained": [
            { "enhancementName": "ShintaoDisciplinedTraining", "selection": "Deft Strikes", "ranks": 3 }
          ]
        }
      ]
    },
    "destinyTrees": { "selected": [], "tier5": null, "spend": [] },
    "reaper": { "pointsAvailable": 60, "spend": [] },
    "trainedSpells": [
      { "class": "Wizard", "level": 1, "name": "Magic Missile" }
    ],
    "stances": ["Centered"],
    "notes": ""
  },
  "breakdown": { "hp": 0, "sp": 0, "saves": {}, "abilities": {} }
}
```

`specialFeats[].type` values: `HeroicPastLife`, `RacialPastLife`, `IconicPastLife`, `EpicPastLife`, `Special`, `Favor`, `UniversalTree`, `EpicDestinyTree`.

`meta.incomplete` (live only): list domains the companion could not read yet, e.g. `feats`, `skills`, `enhancements`, `destiny`, `spells`, `tomes`, `reaper`. Partial sheets are fine — send what you have and expand later with `/live/partial`.

### Live endpoints (companion → “what you are”)

#### `GET /characters/:id/live`

Returns the last live snapshot, or **404** if none yet.

#### `PUT /characters/:id/live`

Full replace of the live sheet (default). Syncs quest profile from the document.

Body: build document (or `{ "document": { … } }`). Optional `meta` / `incomplete` / `source`.

Query `?merge=1` (or body `"merge": true`) performs a merge instead of replace.

#### `PUT /characters/:id/live/partial`

Merge patch (shallow section merge — send whole `build.levels`, `enhancementTrees`, etc. when updating those sections):

```json
{
  "life": { "race": "Human", "alignment": "Lawful Good" },
  "build": {
    "level": 12,
    "classes": ["Fighter", "Rogue", null],
    "levels": [
      { "class": "Fighter", "feats": [{ "name": "Power Attack", "type": "Standard" }], "skills": [] }
    ],
    "enhancementTrees": {
      "selected": ["Fighter"],
      "tier5": null,
      "spend": []
    }
  },
  "pastLives": [{ "name": "Past Life: Fighter", "type": "HeroicPastLife", "stacks": 1 }],
  "reaperPoints": 40,
  "meta": { "incomplete": ["destiny", "spells"] }
}
```

#### `POST /characters/:id/live/promote-to-planned`

Copies the live snapshot into the **planned** builder build. Does not delete or change live. Useful after first companion import so the website builder starts from your real sheet.

### Planned endpoints (builder → “what you plan to be”)

### `GET /characters/:id/build`

Returns `{ role: "planned", life, document, reaperPointsAvailable, breakdownCache, validationErrors, updatedAt }`.

### `PUT /characters/:id/build`

Body: full build document (same shape as `document` above). Recalculates breakdown + validation. Syncs character profile **only if no live snapshot exists** (so companion live data stays authoritative for the quest tracker).

### `PUT /characters/:id/build/partial`

Merge patch:

```json
{
  "life": { "race": "Human" },
  "tomes": { "wis": 8 },
  "build": { "level": 34 },
  "pastLives": [{ "name": "Past Life: Fighter", "type": "HeroicPastLife", "stacks": 1 }],
  "reaperPoints": 40
}
```

### `GET /characters/:id/lives`

Returns `{ lives: [{ id, lifeIndex, isCurrent, name, race, alignment, specialFeats, archivedAt, ... }] }`.

### `POST /characters/:id/lives/reincarnate`

Archives the current life, grants past-life feats from the completed life, opens a new current life.

```json
{
  "name": "New Life",
  "race": "Human",
  "alignment": "Lawful Good",
  "grantHeroic": true,
  "grantRacial": true,
  "additionalPastLives": []
}
```

Website session APIs (cookie auth) mirror these under `/api/characters/:id/live*`, `/api/characters/:id/build*`, and `/api/characters/:id/lives*`. Import a `.DDOBuild` file with `POST /api/characters/:id/build/import` `{ "xml": "..." }` (writes the **planned** build).

---

## Suggested plugin flow

1. `POST /auth/login` → store `token`  
2. On character select: `GET /characters/find?givenName=…&surname=…&server=…`  
   - If none: `POST /characters` with sheet data  
   - Else: `PUT /characters/:id` with classes/level/race + `touchLastLogin: true`  
3. Optionally `POST /characters/:id/login` on zone-in  
4. Optional: `GET /characters/:id/progress` and respect `targetDifficulty` / filters  
5. **Companion sheet sync (live):** `PUT /characters/:id/live` with race/classes/levels/feats/enhancements/destiny/spells/tomes as available; use `/live/partial` for incremental updates; set `meta.incomplete` for unread domains  
6. Optional: `POST /characters/:id/live/promote-to-planned` once if you want the website builder seeded from the live sheet  
7. Optional: `PUT /characters/:id/build` only for **planned** builder edits (not for live in-game state)  
8. For a planned route: `GET /navigation?characterId=&routeId=&match=remake` (if Play on is Any) → show `nextQuest`  
9. Before / on dungeon finish — **resolve the remake**:  
   - `GET /quests/search?q=<quest name>&level=<characterLevel>` (or `/quests/lookup?name=…`)  
   - Pick the row whose `difficulty` / `level` matches the instance that was run  
10. `POST /characters/:id/completions` with that `name`, `difficulty`, concrete `setting`, `durationSeconds`, **`xpEarned`** (actual XP from the game when available), and **`reaperXpEarned`** (RXP when on Reaper and available)  
11. On logout: optional `POST /auth/logout`

---

## Errors

JSON `{ "error": "message" }` with HTTP 400 / 401 / 404 / 500.

Missing/invalid bearer → **401**.

---

## Changelog (API)

### Version 6

- Completions accept/store **`reaperXpEarned`** (Reaper experience / RXP) alongside regular `xpEarned`.
- Aliases: `reaperXp`, `reaper_xp`, `reaper_xp_earned`, `rxp`, `rxpEarned`, `rxp_earned`.
- Website shows RXP on completed quest cards when present; omitting RXP on a later upsert preserves the previous value.
- Feature flag: `completionReaperXp`.

### Version 5

- Completions accept **unknown** remake/challenge values — never reject a finish because difficulty is missing or wrong.
  - `difficulty`: `Heroic` \| `Epic` \| `Legendary` \| `Unknown` (optional; junk → `Unknown`)
  - `setting`: `casual` \| `normal` \| `hard` \| `elite` \| `reaper` \| `unknown` (explicit unknown / unrecognized → `unknown`, not coerced to elite)
- Challenge words sent in `difficulty` (with no `setting`) are remapped to `setting` + `difficulty: "Unknown"`.
- Feature flag: `unknownDifficultyAccepted`.

### Version 4

- **Live vs planned builds:** `GET/PUT /characters/:id/live`, `PUT …/live/partial`, `POST …/live/promote-to-planned`.
- `/build*` is the **planned** character-builder document; `/live*` is the companion in-game sheet.
- Quest profile (classes/level/race/past lives) syncs from **live** when present; planned saves no longer overwrite it.
- Feature flags: `liveSnapshot`, `livePromoteToPlanned`.
- Document schema includes `trainedSpells` and optional live `meta.incomplete`.

### Version 3

- Added `GET /quests/search` autocomplete (all remakes; optional `level` ranking).
- `GET /quests/lookup` now returns `remakes` + `remakeCount` for every tier of that quest name.
- Documented remake model: same `name`, separate `difficulty` / `level` rows (e.g. Haywire Foundry Heroic L9 + Epic L22).
- Completions accept `xpEarned` (actual in-game XP); website stats use it when present, otherwise catalog estimates.
- Login accepts `identifier` / `email` / `username` / `login` + password.
- Feature flags: `questSearch`, `questLookupRemakes`, `completionXp`.

### Version 2

- Play on Any, challenge settings including reaper, remake-aware navigation, progress PUT, clear-all settings, character profiles, builder sync.
