Skip to content

API Routes

Conventions

  • Base URL: https://api.aelghar.com (Workers route)
  • All routes: Content-Type: application/json
  • Auth-protected routes require Authorization: Bearer <access_token> header
  • Unauth routes: /api/auth/*
  • Character context derived from JWT cid claim on all protected routes
  • Standard error envelope: { "error": "snake_case_code", "message": "human readable" }
  • Standard success envelope: { "data": ... } or direct object for single-resource GETs
  • Pagination: cursor-based via ?cursor=<opaque> and ?limit=<n> (default 20, max 100)

Auth Routes

Method Path Auth Description
POST /api/auth/register No Create account
POST /api/auth/login No Login, get token pair
POST /api/auth/refresh Cookie Refresh access token
POST /api/auth/logout Cookie Revoke refresh token

POST /api/auth/register

Request:
{
  "email": string,
  "password": string,        // ≥ 12 chars
  "display_name": string     // 2–30 chars
}

Response 201:
{
  "access_token": string,
  "character_required": true  // signals client to show character creation
}
Set-Cookie: refresh_token=...; HttpOnly; Secure; SameSite=Strict; Max-Age=604800

Errors: 400 email_invalid | 400 password_too_short | 400 display_name_invalid | 409 email_taken

POST /api/auth/login

Request:  { "email": string, "password": string }

Response 200:
{
  "access_token": string,
  "character_id": string | null  // null if no character created yet
}
Set-Cookie: refresh_token=...

Errors: 401 invalid_credentials | 429 rate_limited

POST /api/auth/refresh

Cookie: refresh_token

Response 200: { "access_token": string }

Errors: 401 token_invalid | 401 token_expired

POST /api/auth/logout

Cookie: refresh_token
Response 204: (no body)
Clear-Cookie: refresh_token

Character Routes

Method Path Auth Description
GET /api/characters Yes List characters on account
POST /api/characters Yes Create character
GET /api/characters/:id Yes Get character profile
GET /api/characters/:id/skills Yes Skill list with bands and XP
GET /api/characters/:id/inventory Yes Inventory contents
GET /api/characters/:id/flags Yes Onboarding and system flags
GET /api/characters/:id/codex Yes Codex entries

POST /api/characters

Request:
{
  "name": string,             // 2–30 chars, unique per server
  "lineage": string,          // content schema lineage id
  "village_origin": string,   // village id
  "archetype": string,        // archetype id
  "background": string        // background id
}

Response 201: { "character": CharacterObject }

Errors: 400 invalid_field | 409 name_taken | 409 character_already_exists (one per account at launch)

GET /api/characters/:id

Response 200:
{
  "id": string,
  "name": string,
  "lineage": string,
  "village_origin": string,
  "archetype": string,
  "background": string,
  "location_id": string,
  "location_name": string,
  "coin": integer,
  "hall_ranks": { "adventurers_hall": "F", "crafters_hall": null },
  "active_expedition_count": integer,
  "created_at": integer
}

Board Routes

Method Path Auth Description
GET /api/board Yes Board at character's current location
GET /api/board/:locationId Yes Board at specific location (preview allowed)
POST /api/board/:itemId/accept Yes Accept a board order
GET /api/board/acceptances Yes Character's active acceptances

GET /api/board/:locationId

Query: ?guild=adventurers_hall|crafters_hall|town_board|caravan_board
       ?rank=F|E|D|C|B|A|S           (filter by rank; returns eligible ranks only by default)
       ?status=open                   (default; or: accepted, completed, expired)
       ?cursor=&limit=

Response 200:
{
  "location": { "id": string, "name": string },
  "items": [ BoardItemObject ],
  "cursor": string | null
}

BoardItemObject:

{
  "id": "...",
  "guild": "adventurers_hall",
  "rank": "E",
  "type": "cull_order",
  "display_name": "Cull Order — Dragonmoss Ridge",
  "summary": "Reduce pack size below nuisance threshold.",
  "coin_reward": 80,
  "duration_hours": [6, 10],
  "min_party_size": 1,
  "tags": ["combat", "solo_ok"],
  "expires_in_seconds": 43200,
  "status": "open"
}

POST /api/board/:itemId/accept

Request: {} (body empty; character and expedition data derived server-side)

Response 201:
{
  "acceptance_id": string,
  "expedition_id": string,
  "resolves_at": integer    // Unix timestamp
}

Errors: 400 already_accepted | 400 rank_insufficient | 400 not_at_location | 400 order_full | 409 expedition_conflict (active expedition already running for this character)

Idempotency: server checks (board_item_id, character_id) unique constraint before inserting. Duplicate request returns 200 with existing acceptance, not 201.


Expedition Routes

Method Path Auth Description
GET /api/expeditions Yes Character's active and recent expeditions
GET /api/expeditions/:id Yes Expedition detail
DELETE /api/expeditions/:id Yes Cancel (if not yet resolved)

GET /api/expeditions

Query: ?status=active|resolved|failed|all (default: active)
       ?cursor=&limit=

Response 200:
{
  "expeditions": [ ExpeditionObject ]
}

ExpeditionObject:

{
  "id": "...",
  "template_id": "cull_peltorotta_farmland",
  "display_name": "Cull Order — Dragonmoss Ridge",
  "status": "active",
  "location": "Trevalkaan",
  "destination": "Dragonmoss Ridge",
  "started_at": 1234567890,
  "resolves_at": 1234603200,
  "participants": [ { "character_id": "...", "name": "...", "role": "combat" } ],
  "result": null  // populated when resolved
}

GET /api/expeditions/:id

Same as above but single record. result populated on resolution:

"result": {
  "quality_score": 0.82,
  "outcome": "success",
  "narrative": "The pack was driven from the ridge. Two animals put down outright.",
  "rewards": {
    "coin": 80,
    "items": [ { "item_id": "peltorotta_pelt", "quantity": 3 } ],
    "skill_xp": [ { "skill_id": "combat_melee", "xp": 12 } ]
  },
  "injuries": []
}

Market Routes

Method Path Auth Description
GET /api/market/browse Yes Browse sell orders
GET /api/market/price-index Yes Price index + 7-day history for an item
GET /api/market/buy-orders Yes Active buy orders
POST /api/market/orders/sell Yes Post sell order
POST /api/market/orders/buy Yes Post buy order
POST /api/market/orders/:id/fill Yes Fulfill a buy order
POST /api/market/orders/:id/cancel Yes Cancel own order
POST /api/market/orders/:id/extend Yes Extend order lifetime
GET /api/market/my-orders Yes Own active orders
GET /api/market/auctions Yes Active auction lots
POST /api/market/auctions/:id/bid Yes Place bid
GET /api/market/warehouse Yes Own warehouse holds
POST /api/market/warehouse/:id/collect Yes Collect from hold
POST /api/market/warehouse/:id/list Yes Move hold to sell order

GET /api/market/browse

Query: ?town_id=           (required; defaults to character location)
       ?category=food.preserved
       ?search=trout        (item name search, ≥ 2 chars)
       ?sort=price_asc|price_desc|qty_desc|recent  (default: price_asc)
       ?cursor=&limit=

Response 200:
{
  "town": { "id": string, "name": string },
  "shortage_bands": { "food": "strained", "medicine": "stable" },
  "orders": [ SellOrderObject ],
  "cursor": string | null
}

POST /api/market/orders/sell

Request:
{
  "town_id": string,
  "item_id": string,
  "quantity": integer,
  "price_per_unit": integer,
  "source": "inventory" | "warehouse",
  "warehouse_hold_id": string | null  // required if source=warehouse
}

Response 201:
{
  "order_id": string,
  "fee_paid": integer,
  "expires_at": integer
}

Errors: 400 insufficient_items | 400 order_limit_reached | 400 not_at_location | 400 price_invalid

GET /api/market/price-index

Query: ?town_id=&item_id=

Response 200:
{
  "item_id": string,
  "current_index": 42.5,
  "history": [
    { "recorded_at": integer, "index_value": float }
    // 7 entries (one per day)
  ],
  "multipliers": {
    "scarcity": 1.3,
    "route": 1.0,
    "event": 1.0,
    "tax": 1.0,
    "liquidity": 1.0
  }
}

Town Routes

Method Path Auth Description
GET /api/towns/:id Yes Town state snapshot
GET /api/towns/:id/board Yes Town board items
GET /api/towns/:id/events Yes Active town events
GET /api/towns/:id/projects Yes Active public works

GET /api/towns/:id

Response reads from KV town_snapshot:{id} first; falls back to D1 if KV miss.

Response 200:
{
  "id": string,
  "name": string,
  "health_state": "stable",
  "population": 420,
  "reserves": {
    "food":     { "units": 1200.5, "band": "stable", "days_of_cover": 7.2 },
    "water":    { "units": 880.0,  "band": "stable", "days_of_cover": 8.1 },
    "fuel":     { "units": 340.0,  "band": "strained", "days_of_cover": 3.4 },
    "medicine": { "units": 95.0,   "band": "stable", "days_of_cover": 9.5 },
    "building": { "units": 210.0,  "band": "growing", "days_of_cover": 14.0 }
  },
  "controlling_guild": { "id": string, "name": string, "tag": string } | null,
  "last_tick_at": integer
}

Guild Routes

Method Path Auth Description
GET /api/guild Yes Own guild (from JWT character)
GET /api/guild/members Yes Roster
GET /api/guild/influence Yes Influence panel per town
GET /api/guild/storage Yes Storage contents
POST /api/guild/storage/deposit Yes Deposit item
POST /api/guild/storage/withdraw Yes Withdraw item
GET /api/guild/treasury Officer+ Treasury ledger
POST /api/guild/treasury/distribute Officer+ Distribute coin to members
GET /api/guild/departure-slots Yes Active departure slots
POST /api/guild/departure-slots Officer+ Post new departure slot
POST /api/guild/departure-slots/:id/join Yes Commit to slot
POST /api/guild/departure-slots/:id/leave Yes Leave slot
POST /api/guilds Yes Found a new guild

POST /api/guilds

Request:
{
  "name": string,    // 3–30 chars
  "tag": string,     // 2–5 chars, uppercase
  "co_founder_id": string   // second character required at founding
}

Response 201: { "guild": GuildObject }

Errors: 400 insufficient_renown | 400 insufficient_coin | 409 name_taken | 409 tag_taken


Admin Routes

Protected by X-Admin-Secret: <secret> header (Workers Secret binding) in addition to normal auth. Never expose to browser clients.

Method Path Description
POST /api/admin/content/reload Hot-reload content YAML into D1
POST /api/admin/towns/:id/tick Force manual settlement tick
GET /api/admin/towns/:id/ledger Full daily ledger for a town
POST /api/admin/characters/:id/coin Admin coin adjustment

Messaging Routes

Method Path Auth Description
POST /api/messages Yes Send message
GET /api/messages/inbox Yes Inbox (paginated, 20/page, sent_at DESC)
GET /api/messages/sent Yes Sent folder (paginated)
GET /api/messages/:id Yes Full message; marks read_at if recipient
POST /api/messages/:id/read Yes Explicit read mark (idempotent)
DELETE /api/messages/:id Yes Soft-delete

POST /api/messages

Request:
{
  "recipient_id": string,   // character id
  "subject": string,        // max 120 chars
  "body": string            // plain text, max 2000 chars
}

Response 201: { "message_id": string }

Errors: 400 recipient_not_found | 400 subject_too_long | 400 body_too_long | 400 cannot_message_self | 429 message_rate_limited

Rate limit: 30 messages per account per hour enforced in Worker (D1 count query on send path). WAF rule: 10 req/IP/min on this endpoint.

GET /api/messages/inbox

Query: ?cursor=&limit=20

Response 200:
{
  "unread_count": 3,
  "messages": [
    {
      "id": string,
      "sender_id": string,
      "sender_name": string,
      "subject": string,
      "sent_at": integer,
      "read_at": integer | null
    }
  ],
  "cursor": string | null
}

GET /api/messages/:id

Returns full message body. If caller is recipient and read_at is null: sets read_at, decrements inbox_meta:{character_id} KV unread count.

Response 200:
{
  "id": string,
  "sender_id": string,
  "sender_name": string,
  "subject": string,
  "body": string,
  "sent_at": integer,
  "read_at": integer | null
}

Leaderboard Routes

Method Path Auth Description
GET /api/leaderboard Yes Pre-computed leaderboard from KV
GET /api/leaderboard/me Yes Caller's rank (D1 count, 1h KV cache)

GET /api/leaderboard

Query: ?window=weekly|monthly|all_time   (required)
       ?category=expeditions_completed|coin_earned|items_crafted|town_influence|elite_kills|boss_kills
                                          (required)

Response 200:
{
  "window": "weekly",
  "category": "expeditions_completed",
  "computed_at": integer | null,
  "entries": [
    { "rank": 1, "character_id": string, "display_name": string, "value": integer }
  ]
}

Reads leaderboard:{window}:{category} from KV. No D1 hit. Returns { "entries": [] } if key not yet populated.

GET /api/leaderboard/me

Query: ?window=weekly|monthly|all_time
       ?category=...

Response 200: { "rank": integer, "value": integer }

D1 count query to find caller's rank. Result cached in leaderboard_me:{character_id}:{window}:{category} KV for 1 hour.


Method Path Auth Description
GET /api/characters/search Yes Search characters by display name

GET /api/characters/search

Query: ?q=string   (min 2 chars, max 30)

Response 200:
{
  "results": [
    { "id": string, "display_name": string, "location_name": string }
  ]
}

Returns max 10 results. D1 query: WHERE display_name LIKE ? LIMIT 10. Not cached — low-frequency call from message compose form only.


Error Codes Reference

HTTP Code Meaning
400 validation_error Generic field validation fail
400 not_at_location Character not present at required location
400 rank_insufficient Guild rank too low for this order
400 order_limit_reached Character at max simultaneous orders
400 insufficient_items Inventory or warehouse lacks required items
401 unauthorized Missing or invalid access token
401 token_expired Access token expired; client should refresh
403 forbidden Token valid but insufficient permissions
404 not_found Resource not found
409 conflict State conflict (duplicate, unique constraint)
429 rate_limited Too many requests
503 town_degraded Town DO in degraded state; data unavailable

Degraded State Handling

When a Town DO or Market DO is in degraded state, the Worker returns 503 town_degraded. The client displays a non-blocking amber banner: "Town data temporarily unavailable. Last known state shown." and continues serving from the last KV snapshot. Retries with exponential backoff (1s, 2s, 4s, max 3 attempts) before surfacing the error.