> For the complete documentation index, see [llms.txt](https://axiomemu.gitbook.io/docs/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://axiomemu.gitbook.io/docs/guides/website-achievements.md).

# Website: Achievements

Step-by-step guide for web developers - read a character's achievement progress from the AchievementProgress table and show cards, points and an achievement-points ladder on the website, with full Nod

The game stores each character's achievement progress as one binary row. This guide shows how to read it and show it on the website: a character's achievement page (cards, tiers, progress, points) and a server-wide points ladder.

For what achievements are and how to configure them, see [Achievements](/docs/data/plugins/achievements.md).

## What you can show

| Page                 | Shows                                                                                                                        |
| -------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
| Character profile    | Every achievement as a card: name, text, icon, tier, progress bar, claimed / ready / in progress. Points total in the header |
| Ladder               | Characters sorted by achievement points                                                                                      |
| "Recently completed" | Not possible: completion times are not stored anywhere                                                                       |

## Where the data lives

| Data                        | Where                                                                        | What you get                                       |
| --------------------------- | ---------------------------------------------------------------------------- | -------------------------------------------------- |
| Progress                    | SQL table `AchievementProgress`, column `Blob`                               | One counter and one "claimed" byte per achievement |
| Names, texts, tiers, points | `Data/Plugins/Achievements/*.xml`                                            | Everything you print                               |
| Icons                       | Client files `Data/Interface/UI/atlas/achievements.png` + `achievements.xml` | 48 × 48 icons                                      |

```sql
CREATE TABLE AchievementProgress (
    Name varchar(10)    PRIMARY KEY,   -- character name
    Blob varbinary(1254) NOT NULL
)
```

No row = the character has no progress yet.

{% hint style="info" %}
If the website runs on another machine, copy the `Achievements` folder to it (or export it to JSON) every time you change it on the server.
{% endhint %}

## Step 1 - Read the blob

All numbers are **little-endian** (least significant byte first).

| Offset |      Size | Field                                                                             |
| -----: | --------: | --------------------------------------------------------------------------------- |
|      0 |         1 | Version. Must be `1`. `0` = empty row, no progress                                |
|      1 |         1 | Reserved                                                                          |
|      2 |         2 | Count = number of entries (250)                                                   |
|      4 | 5 × Count | One entry per achievement `Id`, in Id order (entry 0 = Id 0, entry 1 = Id 1, ...) |

Each entry (5 bytes), at offset `4 + Id × 5`:

| Offset | Size | Field                                                                           |
| -----: | ---: | ------------------------------------------------------------------------------- |
|      0 |    4 | `Counter`, signed 32-bit. Below 0 = treat as 0                                  |
|      4 |    1 | `Claimed`, bit mask. Bit 0 = tier 1 claimed, bit 1 = tier 2, ... bit 7 = tier 8 |

Example: Id 14 is at offset `4 + 14 × 5 = 74`. Bytes `58 02 00 00 01` → Counter `600`, tier 1 claimed.

## Step 2 - Load the achievement files

Load the folder the same way the GameServer does:

1. Every `*.xml` in `Data/Plugins/Achievements/`, in alphabetical order.
2. Skip `Settings.xml` and every file whose name starts with `_`.
3. Each file has one `<Category Id Name Order Icon>` root.
4. Each `<Achievement Id Name Icon Text Enabled>` inside it. Skip `Enabled="0"`.
5. Its `<Tier Target Points>` children, in file order. A tier whose `Target` is not bigger than the one before is skipped (the server skips it too). Max 8 tiers.

Keep the first achievement with a given `Id`; the server skips duplicates.

Parse once and cache. Reload when a file changes.

## Step 3 - Work out each card

For one achievement with tiers `T[0..n-1]`, the character's `Counter` and `Claimed`:

| Value            | Rule                                                           |
| ---------------- | -------------------------------------------------------------- |
| Tier `i` reached | `Counter >= T[i].Target`                                       |
| Tier `i` claimed | `Claimed & (1 << i)` is not 0                                  |
| Next target      | `Target` of the first tier **not reached**. None = all reached |
| Progress bar     | `Counter / next target` (full when all reached)                |
| Points earned    | Sum of `Points` of the **claimed** tiers                       |

Card state:

| State          | When                              | Suggested look                                                      |
| -------------- | --------------------------------- | ------------------------------------------------------------------- |
| Complete       | Every tier claimed                | Gold / done badge                                                   |
| Ready to claim | A tier is reached but not claimed | Highlight. Players claim in game (Window Menu **U** → Achievements) |
| In progress    | Counter > 0, nothing to claim     | Normal                                                              |
| Not started    | Counter = 0                       | Dimmed                                                              |

{% hint style="info" %}
Tier completion is worked out from `Counter`, it is not stored. If the server owner raises a `Target`, an unclaimed tier can go back to "not reached". Claimed tiers never change.
{% endhint %}

The character's **achievement points** = sum of "points earned" over every achievement. That is the number the game shows in the window header. **Max points** = sum of every tier's `Points`.

**Counters that keep the highest value**: `ReachLevel`, `Reset` and `ItemUpgrade` achievements store the best value seen (level 350, 12 resets, +13), not a count. Show them as `350 / 400`, the same as the others. `PlayTime` counts minutes.

## Step 4 - Icons

The game icons are in one image (`achievements.png`) with a map of where each icon is (`achievements.xml`):

```xml
<Atlas image="atlas/achievements">
    <Sprite name="ach/balgass" x="0" y="0" w="48" h="48"/>
    <Sprite name="ach/bloodcastle" x="50" y="0" w="48" h="48"/>
```

Copy `achievements.png` to the website and use CSS sprites. An achievement's `Icon="ach/bloodcastle"` → `background-position: -50px 0`. Missing icon → use `ach/default`, then an empty box (the game does the same).

```css
.ach-icon { width: 48px; height: 48px; background: url(/img/achievements.png) no-repeat; }
```

```html
<div class="ach-icon" style="background-position:-50px 0"></div>
```

## Full code

{% tabs %}
{% tab title="Node.js / TypeScript" %}
Uses `mssql` and `fast-xml-parser` (`npm i fast-xml-parser`).

```ts
// achievements.ts
import fs from "fs";
import path from "path";
import sql from "mssql";
import { XMLParser } from "fast-xml-parser";

const ACH_DIR = "C:/MuServer/Data/Plugins/Achievements";   // your server's path

// ---------- Step 1: blob ----------
export interface AchProgress { counter: number; claimed: number }

export function readProgress(blob: Buffer | null | undefined): Map<number, AchProgress> {
  const out = new Map<number, AchProgress>();
  if (!blob || blob.length < 4 || blob[0] !== 1) return out;   // empty row
  const count = Math.min(blob.readUInt16LE(2), 250);
  for (let id = 0; id < count; id++) {
    const p = 4 + id * 5;
    if (p + 5 > blob.length) break;
    const counter = Math.max(0, blob.readInt32LE(p));
    const claimed = blob[p + 4];
    if (counter !== 0 || claimed !== 0) out.set(id, { counter, claimed });
  }
  return out;
}

// ---------- Step 2: config ----------
export interface AchTier { target: number; points: number }
export interface AchDef { id: number; category: number; name: string; icon: string; text: string; tiers: AchTier[] }
export interface AchCategory { id: number; name: string; order: number; icon: string }

let cache: { stamp: string; categories: AchCategory[]; defs: AchDef[] } | null = null;
const asArray = <T>(v: T | T[] | undefined): T[] => (v === undefined ? [] : Array.isArray(v) ? v : [v]);

export function loadAchievements() {
  const files = fs.readdirSync(ACH_DIR)
    .filter((f) => f.toLowerCase().endsWith(".xml") && !f.startsWith("_") && f.toLowerCase() !== "settings.xml")
    .sort();
  const stamp = files.map((f) => `${f}:${fs.statSync(path.join(ACH_DIR, f)).mtimeMs}`).join("|");
  if (cache && cache.stamp === stamp) return cache;

  const parser = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: "", parseAttributeValue: false });
  const categories: AchCategory[] = [];
  const defs: AchDef[] = [];
  const seen = new Set<number>();

  for (const f of files) {
    const cat = parser.parse(fs.readFileSync(path.join(ACH_DIR, f), "utf8")).Category;
    const cid = parseInt(cat?.Id, 10);
    if (!(cid >= 1 && cid <= 16) || categories.some((c) => c.id === cid)) continue;
    categories.push({ id: cid, name: String(cat.Name ?? "Category").slice(0, 31), order: parseInt(cat.Order ?? cid, 10), icon: String(cat.Icon ?? "") });

    for (const a of asArray<any>(cat.Achievement)) {
      const id = parseInt(a.Id, 10);
      if (!(id >= 0 && id <= 249) || String(a.Enabled ?? "1") === "0" || seen.has(id)) continue;
      const tiers: AchTier[] = [];
      for (const t of asArray<any>(a.Tier)) {
        if (tiers.length >= 8) break;
        const target = Math.max(1, parseInt(t.Target ?? "1", 10) || 1);
        if (tiers.length > 0 && target <= tiers[tiers.length - 1].target) continue;   // must ascend
        tiers.push({ target, points: Math.min(65535, Math.max(0, parseInt(t.Points ?? "0", 10) || 0)) });
      }
      if (tiers.length === 0) continue;
      seen.add(id);
      defs.push({ id, category: cid, name: String(a.Name ?? "").slice(0, 31), icon: String(a.Icon ?? ""), text: String(a.Text ?? "").slice(0, 127), tiers });
    }
  }
  categories.sort((x, y) => x.order - y.order);
  cache = { stamp, categories, defs };
  return cache;
}

// ---------- Step 3: cards ----------
export type AchState = "complete" | "claimable" | "progress" | "new";
export interface AchCard {
  def: AchDef; counter: number; reached: number; claimedTiers: boolean[];
  nextTarget: number | null; percent: number; points: number; state: AchState;
}

export function buildCards(progress: Map<number, AchProgress>) {
  const { categories, defs } = loadAchievements();
  let points = 0, maxPoints = 0;

  const cards: AchCard[] = defs.map((def) => {
    const p = progress.get(def.id) ?? { counter: 0, claimed: 0 };
    const claimedTiers = def.tiers.map((_, i) => (p.claimed & (1 << i)) !== 0);
    const reached = def.tiers.filter((t) => p.counter >= t.target).length;
    const next = def.tiers.find((t) => p.counter < t.target) ?? null;
    const earned = def.tiers.reduce((s, t, i) => s + (claimedTiers[i] ? t.points : 0), 0);
    points += earned;
    maxPoints += def.tiers.reduce((s, t) => s + t.points, 0);

    let state: AchState = "new";
    if (claimedTiers.every(Boolean)) state = "complete";
    else if (def.tiers.some((t, i) => p.counter >= t.target && !claimedTiers[i])) state = "claimable";
    else if (p.counter > 0) state = "progress";

    return {
      def, counter: p.counter, reached, claimedTiers,
      nextTarget: next ? next.target : null,
      percent: next ? Math.min(100, Math.floor((p.counter * 100) / next.target)) : 100,
      points: earned, state,
    };
  });

  return { categories, cards, points, maxPoints };
}

// ---------- Character page ----------
export async function characterAchievements(pool: sql.ConnectionPool, name: string) {
  const r = await pool.request()
    .input("name", sql.VarChar(10), name)
    .query("SELECT Blob FROM AchievementProgress WHERE Name = @name");
  return buildCards(readProgress(r.recordset[0]?.Blob));
}

// ---------- Ladder (cache the result for a few minutes) ----------
export async function achievementLadder(pool: sql.ConnectionPool, top = 100) {
  const r = await pool.request().query(
    `SELECT a.Name, a.Blob, c.Class, c.cLevel
       FROM AchievementProgress a
       JOIN Character c ON c.Name = a.Name`);   // drops rows of deleted characters
  return r.recordset
    .map((row) => ({ name: row.Name, cls: row.Class, level: row.cLevel, points: buildCards(readProgress(row.Blob)).points }))
    .filter((x) => x.points > 0)
    .sort((a, b) => b.points - a.points || a.name.localeCompare(b.name))
    .slice(0, top);
}
```

{% endtab %}

{% tab title="PHP" %}
Uses PDO (`sqlsrv`) and SimpleXML.

```php
<?php
// achievements.php
const ACH_DIR = 'C:/MuServer/Data/Plugins/Achievements';   // your server's path

// ---------- Step 1: blob ----------
function readAchProgress(?string $blob): array {
    $out = [];
    if ($blob === null || strlen($blob) < 4 || ord($blob[0]) !== 1) return $out;   // empty row
    $count = min(unpack('v', substr($blob, 2, 2))[1], 250);
    for ($id = 0; $id < $count; $id++) {
        $p = 4 + $id * 5;
        if ($p + 5 > strlen($blob)) break;
        $counter = unpack('V', substr($blob, $p, 4))[1];   // little-endian unsigned
        if ($counter >= 0x80000000) $counter = 0;          // negative as signed = 0
        $claimed = ord($blob[$p + 4]);
        if ($counter !== 0 || $claimed !== 0) $out[$id] = ['counter' => $counter, 'claimed' => $claimed];
    }
    return $out;
}

// ---------- Step 2: config ----------
function loadAchievements(): array {
    static $cache = null;
    $files = array_filter(scandir(ACH_DIR), fn($f) =>
        strtolower(pathinfo($f, PATHINFO_EXTENSION)) === 'xml' && $f[0] !== '_' && strtolower($f) !== 'settings.xml');
    sort($files);
    clearstatcache();
    $stamp = implode('|', array_map(fn($f) => $f . ':' . filemtime(ACH_DIR . "/$f"), $files));
    if ($cache !== null && $cache['stamp'] === $stamp) return $cache;

    $categories = [];
    $defs = [];
    foreach ($files as $f) {
        $cat = simplexml_load_file(ACH_DIR . "/$f");
        if ($cat === false || $cat->getName() !== 'Category') continue;
        $cid = (int)$cat['Id'];
        if ($cid < 1 || $cid > 16 || isset($categories[$cid])) continue;
        $categories[$cid] = ['id' => $cid, 'name' => substr((string)($cat['Name'] ?? 'Category'), 0, 31),
                             'order' => isset($cat['Order']) ? (int)$cat['Order'] : $cid, 'icon' => (string)$cat['Icon']];

        foreach ($cat->Achievement as $a) {
            $id = (int)$a['Id'];
            if ($id < 0 || $id > 249 || (string)$a['Enabled'] === '0' || isset($defs[$id])) continue;
            $tiers = [];
            foreach ($a->Tier as $t) {
                if (count($tiers) >= 8) break;
                $target = max(1, isset($t['Target']) ? (int)$t['Target'] : 1);
                if ($tiers && $target <= end($tiers)['target']) continue;   // must ascend
                $tiers[] = ['target' => $target, 'points' => min(65535, max(0, (int)$t['Points']))];
            }
            if (!$tiers) continue;
            $defs[$id] = ['id' => $id, 'category' => $cid, 'name' => substr((string)$a['Name'], 0, 31),
                          'icon' => (string)$a['Icon'], 'text' => substr((string)$a['Text'], 0, 127), 'tiers' => $tiers];
        }
    }
    uasort($categories, fn($x, $y) => $x['order'] <=> $y['order']);
    return $cache = ['stamp' => $stamp, 'categories' => array_values($categories), 'defs' => array_values($defs)];
}

// ---------- Step 3: cards ----------
function buildAchCards(array $progress): array {
    $cfg = loadAchievements();
    $points = 0;
    $maxPoints = 0;
    $cards = [];

    foreach ($cfg['defs'] as $def) {
        $p = $progress[$def['id']] ?? ['counter' => 0, 'claimed' => 0];
        $earned = 0;
        $reached = 0;
        $next = null;
        $allClaimed = true;
        $claimable = false;
        foreach ($def['tiers'] as $i => $t) {
            $isClaimed = ($p['claimed'] & (1 << $i)) !== 0;
            $isReached = $p['counter'] >= $t['target'];
            if ($isClaimed) $earned += $t['points'];
            if ($isReached) $reached++;
            elseif ($next === null) $next = $t['target'];
            if (!$isClaimed) $allClaimed = false;
            if ($isReached && !$isClaimed) $claimable = true;
            $maxPoints += $t['points'];
        }
        $points += $earned;

        $state = $allClaimed ? 'complete' : ($claimable ? 'claimable' : ($p['counter'] > 0 ? 'progress' : 'new'));
        $cards[] = [
            'def' => $def, 'counter' => $p['counter'], 'reached' => $reached, 'nextTarget' => $next,
            'percent' => $next === null ? 100 : min(100, intdiv($p['counter'] * 100, $next)),
            'points' => $earned, 'state' => $state,
        ];
    }
    return ['categories' => $cfg['categories'], 'cards' => $cards, 'points' => $points, 'maxPoints' => $maxPoints];
}

// ---------- Character page ----------
function characterAchievements(PDO $db, string $name): array {
    $st = $db->prepare('SELECT Blob FROM AchievementProgress WHERE Name = ?');
    $st->execute([$name]);
    $st->bindColumn(1, $blob, PDO::PARAM_LOB, 0, PDO::SQLSRV_ENCODING_BINARY);
    $found = $st->fetch(PDO::FETCH_BOUND);
    if ($found && is_resource($blob)) $blob = stream_get_contents($blob);
    return buildAchCards(readAchProgress($found ? $blob : null));
}

// ---------- Ladder (cache the result for a few minutes) ----------
function achievementLadder(PDO $db, int $top = 100): array {
    $st = $db->query('SELECT a.Name, a.Blob, c.Class, c.cLevel FROM AchievementProgress a JOIN Character c ON c.Name = a.Name');
    $st->bindColumn(1, $name);
    $st->bindColumn(2, $blob, PDO::PARAM_LOB, 0, PDO::SQLSRV_ENCODING_BINARY);
    $st->bindColumn(3, $class);
    $st->bindColumn(4, $level);
    $rows = [];
    while ($st->fetch(PDO::FETCH_BOUND)) {
        $data = is_resource($blob) ? stream_get_contents($blob) : $blob;
        $pts = buildAchCards(readAchProgress($data))['points'];
        if ($pts > 0) $rows[] = ['name' => $name, 'class' => (int)$class, 'level' => (int)$level, 'points' => $pts];
    }
    usort($rows, fn($a, $b) => $b['points'] <=> $a['points'] ?: strcmp($a['name'], $b['name']));
    return array_slice($rows, 0, $top);
}
```

{% endtab %}
{% endtabs %}

Rendering a card (React example):

```tsx
<div className={`ach-card ach-${card.state}`}>
  <div className="ach-icon" style={iconStyle(card.def.icon)} />
  <b>{card.def.name}</b>
  <span>Tier {card.reached} / {card.def.tiers.length}</span>
  <p>{card.def.text}</p>
  <progress value={card.percent} max={100} />
  <small>{card.nextTarget === null ? "Done" : `${card.counter} / ${card.nextTarget}`}</small>
</div>
```

Group the cards by `card.def.category` and show the categories as tabs, in `order`, like the game.

## Worked example

`02 - Progression.xml` has (rewards left out):

```xml
<Achievement Id="14" Name="Devoted" Icon="ach/playtime" Text="Spend time in the world (minutes online).">
  <Trigger Type="PlayTime"/>
  <Tier Target="60"   Points="5"/>
  <Tier Target="600"  Points="10"/>
  <Tier Target="6000" Points="25"/>
</Achievement>
```

Blob bytes 74-78 (Id 14): `58 02 00 00 01`.

|               |                                                  |
| ------------- | ------------------------------------------------ |
| Counter       | `0x00000258` = 600                               |
| Claimed       | `0x01` = tier 1 claimed                          |
| Reached       | tiers 1 and 2 (600 ≥ 60, 600 ≥ 600)              |
| Next target   | 6000 → bar at 10%                                |
| Points earned | 5 (only tier 1 claimed)                          |
| State         | **Ready to claim** (tier 2 reached, not claimed) |

## Important Behavior

* **Data delay.** Counters are saved every `AutosaveSec` seconds (default 180) and on logout. Tier completions and claims save at once. So an online player's progress bars can be up to 3 minutes behind; claimed tiers and points are always current.
* **Rows by character name.** A character created after the plugin was enabled has no row until it first logs in.
* **Deleted characters** keep their row. Join with `Character` (as the ladder code does) so they do not show up.
* **Plugin off or unlicensed.** The rows stay, the game shows nothing. The website cannot see the license; if the paid module is not licensed, do not show the pages.
* **Removed achievement.** Its progress stays in the blob but there is no definition, so it is not shown. Never reuse an `Id` for a different achievement.
* **Rewards** are not needed on the website. Players claim in game; the site only shows status.

## Common Issues

{% hint style="warning" %}
**Every character shows 0.** The blob was read as a string, or the version byte check failed. `Blob` must come back as raw bytes; with PHP `pdo_sqlsrv` use `PDO::SQLSRV_ENCODING_BINARY`.
{% endhint %}

{% hint style="warning" %}
**Wrong counters, huge numbers.** Bytes were read big-endian. `58 02 00 00` must give 600.
{% endhint %}

{% hint style="warning" %}
**Points differ from the game window.** Count only **claimed** tiers. Reached-but-unclaimed tiers give no points until the player clicks Claim.
{% endhint %}

{% hint style="warning" %}
**Achievements missing on the site.** The website's copy of the folder is old, or a file the server skips (`_` prefix, `Settings.xml`) was loaded, or the other way round.
{% endhint %}

## Related pages

* [Achievements](/docs/data/plugins/achievements.md) - triggers, tiers, rewards, settings
