# Kiwi API > Kiwi is an HTTP API platform. This file is a self-contained reference written for > LLM coding assistants - paste it in as context when generating Kiwi client code. > The authoritative machine spec is at https://api.aallyn.net/openapi.json > > STATUS: 1.0 platform (accounts, API tokens, rate limiting, usage metrics) with > the first data endpoints live: Trove game data under `/v1/rotations/*`. More product > endpoints are added on top over time - `GET /openapi.json` is always current. > > IMPORTANT: Programs authenticate ONLY with an API token. Account sign-up, > sign-in, and token creation are done by a human in the browser developer portal > (https://dev.aallyn.net). There is NO programmatic login endpoint - never call > `/auth/*` from code, and don't generate sign-in/sign-up requests. The API token > is the only credential a program uses. ## Base URLs - `https://api.aallyn.net` - the data API (`/v1/*` once published, `/health`). This is what your code calls. - `https://dev.aallyn.net` - the developer portal (a website). Humans use it to sign up and manage tokens; programs do not call it. ## Authentication 1. A human creates an account and an **API token** in the portal (browser). 2. The token (`kiwi_<43 base62>_`, regex `kiwi_[A-Za-z0-9]{43}_[A-Za-z0-9]{1,6}`) is shown once at creation; store it securely. 3. Programs send it as a bearer credential on every request: `Authorization: Bearer kiwi_...` Tokens are scoped and IP-restricted, and may expire. Enforcement per request: - IP not on the token's allowlist → `403 ip_not_allowed` - token missing a required scope → `403 insufficient_scope` - revoked/expired token → `401 not_authenticated` There is no refresh, no session, no login round-trip - the token is the credential. ## Conventions - All request/response bodies are JSON (`Content-Type: application/json`). - **Errors** always use this envelope (branch on `code`, not `message`): ```json { "error": { "code": "rate_limited", "message": "…", "details": { } } } ``` - **Rate limits**: responses carry `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset` (unix seconds). A `429` adds `Retry-After` (seconds). - Default data-API limit: **120 requests / minute / token**. ## Error codes | HTTP | code | meaning | |------|------|---------| | 400 | `bad_request` | malformed request | | 401 | `not_authenticated` | missing/invalid/expired/revoked API token | | 403 | `insufficient_scope` | token lacks the required scope | | 403 | `ip_not_allowed` | request IP not in the token's allowlist | | 403 | `forbidden` | authenticated but not allowed | | 404 | `not_found` | resource missing or not yours | | 422 | `validation_error` | body validation failed (`details` lists fields) | | 429 | `rate_limited` | rate limit exceeded (see `Retry-After`) | | 500 | `internal_error` | unexpected server error | ## Scopes (bitmask) A token carries a bitmask of scopes named `:`. `0` = all scopes (present + future); otherwise OR the bits you need. Currently defined: - `rotations:read` (bit 1) - server time, bonuses, merchants, biomes - **public** (usable with no token, see below) - `feeds:read` (bit 2) - news, Twitch streams, YouTube/Bilibili videos, Trovesaurus event calendar - **public** - `stats:read` (bit 4) - raw stat tables (power rank / magic find / light) and class definitions - `gems:read` (bit 8) - gem simulator (generate/augment), evaluator, and build optimizer - `misc:read` (bit 16) - third-party modding-software list, time converter, news archive, the market interest-items list, and the supporters credits list (`/v1/misc/interest-items` and `/v1/misc/supporters` are **public** - no token needed) - `mods:read` (bit 32) - decompile a `.tmod` file and build a `.tmod` from files - `updates:read` (bit 64) - browse archived game files: versions, per-version changes, directory tree, single-file download - `codexes:read` (bit 128) - read parsed game codexes (allies, mounts, dragons, mementos, styles, recipes, items, fish, badges) - **public** - `btt:read` (bit 256) - read BetterTroveTools releases (latest version per platform, per channel) - **public** - `leaderboards:read` (bit 512) - read Trove in-game leaderboards (boards, entries, timestamps, player history) - `market:read` (bit 1024) - read Trove marketplace listings (active listings, distinct items, interest list, per-item summary) - `activity:read` (bit 2048) - read Trove player-activity estimates (live active-player count + multi-period trend series) - **public** - `giveaways:read` (bit 4096) - read public giveaways (ongoing draws, prizes, entry counts) - **public** - `events:read` (bit 8192) - subscribe to the live SSE event stream (real-time rotation/feed pushes: challenge, chaos, merchants, biomes, status, news, giveaways, activity, and `game_update` when a new build is mirrored) - **public** - `ocr:read` (bit 16384) - read character stats from a stat-sheet screenshot via self-hosted OCR (token required - CPU-heavy) ## Endpoints - Data (base: https://api.aallyn.net, auth: API token) Grouped by function. Rotations/feeds/stats are GET, read-only (timestamps real-UTC **unix seconds**); the gem/misc tools add POST calculators. Full field schema in `/openapi.json`. **`/v1/rotations/*`, `/v1/feeds/*`, `/v1/codexes/*`, `/v1/btt/*`, `/v1/activity/*` and `/v1/events/*` are PUBLIC** - callable with no token at a stricter per-IP rate limit (30 req/min/IP by default). Send a token carrying the matching scope to get the full per-token limit (120 req/min). **`/v1/codexes/*` gets 5× both budgets** (150 req/min/IP anon, 600 req/min/token). A malformed/revoked token still 401s; a valid token without the scope is served on the anonymous per-IP budget. A few individual endpoints are also tokenless (`POST /v1/stats/coefficient`, `/v1/misc/interest-items`, `/v1/misc/supporters`, `/v1/misc/feedback`, the `/v1/updates` file downloads). All other surfaces require a token with the matching scope: `/v1/stats/*` need `stats:read`; `/v1/gems/*` need `gems:read`; `/v1/misc/*` need `misc:read`; `/v1/mods/*` need `mods:read`; `/v1/updates/*` need `updates:read`; `/v1/leaderboards/*` need `leaderboards:read` and `/v1/market/*` need `market:read` (read sides; both have a `/insert` endpoint that is **master-only** - it requires a superuser API token). **Live event stream (SSE).** Push instead of poll: rather than hammering `chaos-chest` / `challenge/current` at the top of the hour, subscribe once and receive updates the instant a capture lands. - `GET /v1/events/stream` (scope `events:read`, **public**) → a long-lived `text/event-stream`. On connect you get a snapshot of every registered source's current state, then one message per change. Each message is an `event: ` line + a JSON `data: {type, data, ts}` payload, where `type` ∈ `challenge` | `chaos` | `corruxion` | `fluxion` | `longshade` | `wild_mana` | `stampy` | `daily_bonuses` | `activity` | `server_status` | `trove_news` | `giveaways` | `game_update` | `mod_release`, and `data` mirrors the matching REST body (e.g. `/challenge/current`, `/chaos-chest`; `game_update` carries `{version:{branch,ordinal,version_tag, files_added,files_modified,files_removed,...}}` for the latest live-US build). `mod_release` is a **discrete** event (not in the on-connect snapshot) fired when a Mods-Hub release is published: `data = {project:{slug,title,owner}, release:{id,tag,title,branch,format,size,changelog,published_at}, download_url, page_url}` - hook it to mirror/announce new mod releases. A `: ping` keep-alive comment arrives ~every 20s; reconnect on disconnect and the snapshot re-primes you. Example: `curl -N https://api.aallyn.net/v1/events/stream`. - **Don't want to hold a connection?** The website **User Dashboard** (Discord login at trove.aallyn.net) lets a user register a **Discord webhook** that Kiwi POSTs a rendered embed to when a `challenge`, `mod_release`, or `game_update` event fires - a push alternative to the SSE stream for "post it to my Discord channel". The embed is **fully customizable** (title, color, fields, footer, image, message text) with `{variable}` placeholders and a live preview; the same editor customizes the Kiwi Discord **bot's** per-server announcements. The embed image can be a custom one from the **Image Studio** (Dashboard): a freeform, server-rendered image designer (background + text/shape/image layers) whose text can carry the same `{variables}`. Standalone, a design renders at a public URL (`GET /site/images/{id}.png`); as an embed image you pick the design from a dropdown and the bot/webhook **renders + uploads it fresh per post** (a Discord attachment, not a URL - so live data in the image never goes stale behind Discord's image cache). It's a Dashboard feature (no API-token surface), so it's not in this reference. - `GET /v1/rotations/server-time` → `{ now_unix, now_iso, trove_day, daily_reset_at, weekly_reset_at }` - `GET /v1/rotations/daily-buffs` → `{ current: Buff, week: [Buff x7] }` where Buff = `{ name, color, weekday, emoji, normal_buffs[], premium_buffs[], icon, banner }` - `GET /v1/rotations/weekly-buffs` → `{ current: Buff, rotation: [Buff x4] }` where Buff = `{ name, color, weekday, emoji, buffs[], banner }` - `GET /v1/rotations/corruxion` → `{ active, starts_at, ends_at, seconds_remaining, schedule: [{starts_at, ends_at}] }` - `GET /v1/rotations/fluxion` → like corruxion plus `state` (`voting`|`selling`|`away`); each schedule item has `state` - `GET /v1/rotations/gardening` → `{ two_day: W, three_day: W, upcoming: [W] }`, W = `{ name, active, starts_at, ends_at }` - `GET /v1/rotations/chaos-chest` → `{ active, starts_at, ends_at, seconds_remaining, item: { name, identifier, blueprint } | null, fetched_at }` - weekly featured item. Source preference: the bot-captured item for the current week (read from the in-game cfg) wins; falls back to the Trovesaurus relay when the bot hasn't reported the current week yet. - `GET /v1/rotations/chaos-chest/history?limit=50&offset=0` → `{ items: [{ week_anchor, week_ends_at, name, captured_at }], count, total }` - past chaos-chest captures, newest week first. - `POST /v1/rotations/chaos-chest/insert` body `{ name }` - **master only** (superuser API token). Server infers the week anchor (Tue 11:00 UTC) from now; idempotent on the (week) level. Subject to the **ingest cooldown** (per-token, per-endpoint; default 1 per 300s; API-token only - session-JWT replays bypass). - `GET /v1/rotations/challenge/current` → `{ starts_at, ends_at, active, is_friday_window, seconds_remaining, name | null, type | null, captured_at | null }` - the hourly challenge active right now. Cadence: one per hour on most days; on trove Fridays (real-UTC Fri 11:00 → Sat 11:00) it's two per hour (:00 and :30). Each window lasts 20 minutes; `active=false` during the gap that follows. `name` is null when the bot hasn't reported this window yet. `type` is server-derived from `name` using literal case-sensitive matches: `"collection"` (name == `"Collection Challenge"`), `"rampage"` (name == `"RAMPAGE ALERT!"`), `"racing"` (name == `"Racing Challenge"`), `"target"` (name == `"Target Challenge"`), or `"dungeon"` (anything else - every other captured string is a biome-themed Dungeon Challenge). - `GET /v1/rotations/challenge/history?limit=50&offset=0` → `{ items: [{ window_anchor, window_ends_at, name, type, is_friday_window, captured_at }], count, total }` - past challenge captures, newest window first. Same `type` classification as `/challenge/current`. - `POST /v1/rotations/challenge/insert` body `{ name }` - **master only**. Server infers the 20-min window anchor from now; `"none"` / empty name returns 400 (the bot is expected to skip those client-side). Subject to the **ingest cooldown** (per-token, per-endpoint; default 1 per 300s; API-token only - session-JWT replays bypass). - `GET /v1/rotations/calendar` → `{ starts_at, ends_at, generated_at, count, events: [E] }`, E = `{ type, name, starts_at, ends_at, color?, state?, biomes?:[{name,icon}] }` - every recurring rotation (weekly_buff / corruxion / fluxion / gardening_2 / gardening_3 / stampy / mana) flattened across ±365 days, sorted by start. Invasion excluded. - `GET /v1/rotations/delves[?week=]` → `{ week, is_current, total, count, fetched_at, depths: [floor] }` - a week's delve rotation, floor records relayed from a community delve source (passed through; rich `{ id, depth, biome, zone, boss, enemies, objective, … }`). Defaults to the current week. **Gated behind the `feature_delves_enabled` master toggle (default OFF) - 404s when the feature is disabled.** - `GET /v1/rotations/delves/weeks` → `{ current_week, items: [{ week, total, count, fetched_at }], count }` - available weeks (metadata only), newest first. Same `feature_delves_enabled` gate as `/delves`. - `GET /v1/rotations/biomes` → `{ current: R | null, upcoming: [R] }`, R = `{ starts_at, ends_at, biomes: [{name, final_name, icon}] }` (3-hour d15 rotation, 3 biomes) - `GET /v1/rotations/wild-mana` → same shape as /v1/rotations/biomes (weekly, 3 biomes) - `GET /v1/rotations/stampy` → same shape (weekly, 48h window, 1 biome) - `GET /v1/feeds/news?limit=20` (1–50) → `{ items: [{ title, url, author, summary, category, categories[], image, published_at }], count }` - latest only (small live view). Full archive: `/v1/misc/news-history`. - `GET /v1/feeds/twitch` → `{ items: [{ channel, login, url, title, viewers, game, started_at, thumbnail }], count, fetched_at }` (live streams) - `GET /v1/feeds/youtube` → `{ items: [{ title, url, channel, video_id, published_at, thumbnail_url }], count, fetched_at }` - `GET /v1/feeds/bilibili` → same shape as youtube - `GET /v1/feeds/events[?category=&limit=]` → `{ items: [Event], count }` - events happening **now**. Event = `{ event_id, name, url, category, image, icon, lookup, starts_at, ends_at, status, seconds_until }` (unix seconds, real UTC) - `GET /v1/feeds/events/categories` → `{ categories: [string], count }` - distinct categories (dynamic; new ones appear as events arrive) - `GET /v1/feeds/events/upcoming[?category=&limit=]` → events not yet started (soonest first); `status="upcoming"` - `GET /v1/feeds/events/history[?category=&limit=]` → events already ended (most recently ended first); `status="ended"` - `GET /v1/stats/power-rank` → `{ stat, label, sources: [Source], count }`, Source = `{ name, value, type, percentage, step, permanent }` - `GET /v1/stats/magic-find` → same shape (Magic Find sources) - `GET /v1/stats/light` → same shape (Light sources; `step`/`permanent` populated) - `GET /v1/stats/classes` → `{ items: [Class], count }` - all 18 classes, each carrying its `tech_name` - `GET /v1/stats/classes/{tech_name}` → one Class by token, e.g. `knight`, `adventurer` (404 `not_found` if unknown). Class = `{ tech_name, name, shorts[], damage_type, weapons[], attributes[], stats: [{name,value,percentage}], bonuses: […], subclass: {name, description, level: {"1":[…]…}, power: {…}}, abilities: [{name,icon,type,stages:[{name,base,multiplier}]}] }` - `POST /v1/stats/coefficient` (**tokenless**) `{ physical_damage?, magic_damage?, critical_damage }` → `{ coefficient, damage_used: "physical"|"magic", formula }`. Stateless calculator for the in-game character **Coefficient** = `floor(max(physical_damage, magic_damage) * (1 + critical_damage/100))` (`critical_damage` is a percent, e.g. `3438.3`); the higher of the two damage stats is used. Provide `critical_damage` + at least one damage stat (else **400**). Same formula `POST /v1/ocr/character` derives Coefficient with. For merchants, `seconds_remaining` counts down to leaving (when `active`) or arriving (when not). News is relayed from `trovegame.com`; Twitch/YouTube/Bilibili are fetched at source (Twitch Helix, YouTube Data API, Bilibili scrape) - all cached server-side in `FeedCache`. Events are stored (kept after they leave the upstream feed) so history/upcoming work; `status` is `upcoming`|`ongoing`|`ended` and `seconds_until` counts down to start (upcoming) or end (ongoing). Stats are raw game data (no calculation): stat tables list each source and how much it gives; `tech_name` is the stable token to reference a class by (display `name` differs, e.g. `adventurer`→"Boomeranger"). ### Gems (`gems:read`) - stateless; gem objects round-trip through the client A gem is a JSON object `{id, tier, type, element, restriction?, ability?, level, stats:[…], augmentation?, + computed: gem_name, quality, power_rank, stat_values, …}`. A stat is `{type, locked, containers:[{base, augments:[{type,count}]}]}`. Nothing is stored server-side - you hold the gem and POST it back to mutate it. Use `GET /v1/gems/lookups` for all valid ids (tiers 1-4, types 1 Lesser/2 Empowered, elements 1-4, stat types 1-9, augments 1 Rough/2 Precise/3 Superior). - `POST /v1/gems/generate` body `{tier?, type?, element?, restriction?, level?, augmentation?}` (omit→random) → a gem object - `POST /v1/gems/augment` body `{gem, stat_position (0-2), augment_type (1-3)}` → `{applied, gem}` (applied=false if the stat is already maxed) - `POST /v1/gems/spark` · `flare` body `{gem, stat_position}` → `{applied, gem}` (reroll stat type / move a proc) - `POST /v1/gems/level-up` body `{gem}` · `set-level` body `{gem, level}` → `{applied, gem}` - `POST /v1/gems/evaluate` body `{tier, type, level, stats:[{type,value,extra_containers}]×3, auto_guess_procs?}` → `{quality_percent, calculated_power_rank, stats[], focus_totals, headline_cost, …}` - `POST /v1/gems/evaluate-simple` body `{tier, type, power_rank, level}` → `{quality_percent, min_power_rank, max_power_rank, is_within_range, focus_totals, headline_cost, …}` (estimate quality from Power Rank alone - no per-stat input) - `GET /v1/gems/stat-range?tier&type&stat_type&level&extra_containers[&element]` → `{min_value, max_value, thresholds, …}` - `GET /v1/gems/builds/options` → valid build-config values (classes, allies, foods, flags) - `POST /v1/gems/builds/calculate` body BuildConfig `{build_type, character, subclass, food, ally, critical_damage_count, no_face, light, subclass_active, litany, berserker_battler, star_chart?}` → `{results:[{rank, layout, coefficient, …}], count}` (top 200 by damage coefficient) ### Misc (`misc:read`) - `GET /v1/misc/news-history?limit=50&offset=0` (limit 1–200) → `{ items: [NewsItem], count, total }` - the full news archive (never pruned), newest first, paginated. Same item shape as `/v1/feeds/news`. - `GET /v1/misc/software` → `{categories:[{key, description, software:[{name, free, url, description}]}], count}` (Trove modding tools). **Public** (tokenless; a `misc:read` token earns the wider per-token rate budget). - `GET /v1/misc/timezones` → `{items:[{id, name}], count}` - supported zones (`trove`, `UTC`, IANA ids). **Public** (tokenless; a `misc:read` token earns the wider rate budget). - `GET /v1/misc/time/now` → `{unix, zones:[{id, name, datetime, date, time}]}` - current time in every zone (Trove "server" time = UTC−11). **Public** (tokenless; a `misc:read` token earns the wider rate budget). - `GET /v1/misc/interest-items` → `{items:[], count}` - sorted allow-list of item names the market scraper bot tracks. **Public** (tokenless OK; a token carrying `misc:read` still earns the wider per-token rate budget). DB-backed; managed via `/admin/market/interest-items` (superuser session, master panel). Seeded from `gamedata/market_items.json` on first boot. - `GET /v1/misc/supporters` → `{supporters:[], count}` - the project's supporters credits list, in display (insertion) order. **Public** (tokenless OK; a token carrying `misc:read` still earns the wider per-token rate budget). Same list shown on the `/support` page; admin-managed via `/admin/supporters` (superuser session, master panel). Seeded with the initial credits on first boot. Cached 5 min. - `GET /v1/misc/trove-status` → `{ overall, auth:{online,http_status,latency_ms,error}, environments:{ eu:{status,online,game:{online,host,port,latency_ms,error}}, us:{...}, pts:{...} }, checked_at }` - **Public, tokenless.** Live Trove server status from a background prober that runs every ~60s (served from cache, never blocks). `overall` rolls up the public Live regions EU+US ∈ `online`/`maintenance`/`down`/`unknown` (all online → online; all down → down; mixed/partial → maintenance). **auth**: HTTPS liveness of `auth.trionworlds.com` (the shared account-auth gateway) - structured response (<500) + valid TLS = reachable; timeout/refused/TLS-error/5xx = down. Catches full outages but stays UP during world-only maintenance (Akamai). **environments.{eu,us,pts}.game**: a probe of the per-env glsserver port (6560 - the stable login-to-game entry; world-instance ports like :3701x are ephemeral). A bare TCP connect is insufficient: a region in MAINTENANCE still completes the TCP handshake and answers the glsserver hello, then drops the connection (verified in EU's maintenance capture - every attempt got SYN/ACK, a fixed hello reply, empty frames, then FIN, and the client kept retrying), so connect-only reads a false `online`. The **deep probe** (default on, `trove_status_game_deep_probe`) replays a captured glsserver client hello (PER-ENV: `trove_status_{eu,us,pts}_hello_hex`) and counts the region online only if the server HOLDS the socket open for `trove_status_game_hold_seconds` (~1.5s); a server that closes right after a near-empty reply is `maintenance` (measured: EU FINs ~70ms when down vs US/PTS holding 6-28s when up). EU/US are game glsservers that hold a hello-only probe open, so the deep probe works there. **PTS uses an EMPTY hello → connect-only**, because its endpoint (`auth-pcpts01`) is an AUTH gateway that drops a hello-only probe even when up (it would false-flag maintenance). Any anomaly (reset, substantial reply, error) falls back to the connect-only verdict (never worse than before). Per-env verdict: `down` if auth fails; `maintenance` if auth up but the game socket refused/dropped; `online` if auth up + game socket held. Endpoints + deep-probe knobs are runtime-tunable in the admin panel. - `GET /v1/misc/trove-status/history?env=eu&days=30` (env ∈ eu/us/pts, days 1–90) → `{ env, days, window_start, window_end, uptime, covered_seconds, segments:[{status,online,started_at,ended_at,duration_seconds}], outages:[...] }` - **Public, tokenless.** Status timeline for one environment, persisted as segments (a new segment opens on every status change; the current open one has `ended_at=null`). `outages` is the non-`online` subset; `uptime` is the online fraction over the covered span. Backs the `/status` page downtime-history graphic. - `POST /v1/misc/time/convert` body `{datetime?: "2026-06-05T14:30", timezone: "trove"|"UTC"|IANA, unix?: int}` → `{unix, iso_utc, zones:[{id, name, datetime, date, time}], discord:[{style, label, code:""}]}`. Give a naive `datetime` interpreted in `timezone`, or an absolute `unix` (timezone ignored). Discord codes render in each viewer's own local time. **Public** (tokenless; a `misc:read` token earns the wider rate budget). - `POST /v1/misc/feedback` - **multipart/form-data** with fields `message` (5..2000 chars, required), `contact?` (≤200 chars), `category?` (`"bug"|"feature"|"general"`, default `"general"`), `app_version?` (≤64 chars, useful for desktop/3rd-party clients), and up to 4 image `attachments` (each ≤5 MB, MIME `image/png|jpeg|webp|gif`). Returns `{ok: true, received_at}`. **Public, tokenless**, rate-limited per-IP + globally (runtime-tunable, defaults 3 / hour / IP and 60 / hour globally). 429 with `Retry-After` + `X-RateLimit-*` headers on per-IP excess; global cap fails silently. The submitter IP is never persisted; only the message, contact, category, app_version, parsed OS + client (from User-Agent), and the attachment metadata (filename/type/size - bytes go to Discord, not Mongo). On success the entry + attachments are POSTed to the Discord webhook in `feedback.discord_webhook` (runtime config) as a fire-and-forget background task - webhook failures don't fail the submission. ### Mods (`mods:read`) - .tmod decompile + build (stateless; up to 20 MB body) - `POST /v1/mods/read` - body is the **raw .tmod bytes** (`application/octet-stream`), query `?metadata_only=true|false`. Returns `{version, header_size, properties:{modLoader, title, author, flags?, …}, file_count, files:[{path, index, offset, size, checksum, content_base64?}], metadata_only, flags, categories[]}`. `metadata_only` omits `content_base64`. `checksum` is Trove's FNV-1a-variant (32-bit). Files/paths are lowercase posix. `flags` is the category bitmask decoded into `categories` (labels) for convenience (see `/v1/mods/categories`). - `POST /v1/mods/build` - JSON `{version?: 1, properties: {title, author, modVersion, notes, tags, …}, files: [{path, content_base64}]}` → **raw .tmod bytes** (`application/octet-stream`, `Content-Disposition` attachment). Built in memory and discarded after sending. The `modLoader` header is always stamped `KiwiAPI` (any client-sent value is overridden). If `properties.tags` contains category names and no `flags` is set, a `flags` bitmask is auto-stamped from them. ### Mods catalog (`mods:read`, tokenless) - browse the shared Mods Hub App-facing read API for the Mods Hub (below). A mod is addressed by **`/`** (handle = the owner's username; slugs are unique per owner, not globally). Returns **absolute** image / download / page URLs. A *mod* object carries `{slug, handle, title, summary, description, readme_text, warnings, tags, categories[], flags, author, is_stray, uploaded_on_behalf, banner_url, preview_urls[], download_count, downloads_7d, star_count, popularity_score, discord_url, website_url, donation_urls[], page_url, created_at, updated_at, forked_from, inspired_by, fork_count}` (`readme_text` is the releases-only long-form README; `warnings` is `
`-split warning text; **`is_stray`** marks an unclaimed "stray" mod - uploaded via contributions and not yet tied to a user account - addressed at `stray/` and claimable by its author; **`uploaded_on_behalf`** marks an *uploaded* mod - a user shared a mod someone else made: the uploader owns it (shown as "Uploaded by …") while `author` credits the named creator ("Created by …"). It is releases-only and NOT claimable, distinct from an *authored* mod where the uploader is the creator); detail + lookup also include `releases[]` = `{tag, branch, title, changelog, format, filename, size, sha256, download_count, download_url, published_at}` (`filename` for a `.tmod` is the artifact's internal `title` property + `.tmod` - Trove matches a mod by that title in-game, so the download keeps that exact name). - `GET /v1/mods` - list published mods (cards, no releases). Query `q` (case-insensitive substring over title/summary/tags/author), `tag` (filter by one tag/category), `author`, `sort` ∈ `recent|popular|downloads|stars|new|title`, `limit` (≤100), `offset`. Returns `{items, count, total, limit, offset}`. - `GET /v1/mods/{handle}/{slug}` - full metadata + published releases for one mod (404 if not public). - `GET /v1/mods/popular` - the **25 most popular** mods (`limit` ≤25). `popularity_score` is a 0.0-1.0 metric derived mainly from the **last 7 days** of downloads (with stars + lifetime downloads as a floor), top mod ≈ 1.0. - `POST /v1/mods/lookup` - JSON `{hashes: [sha256, …]}` (≤200). Resolves mod + release metadata from artifact **content hashes** → `{results: {: {mod, release}}, unknown: [, …]}`. Identify installed `.tmod`s. - `GET /v1/mods/categories` - the fixed category vocabulary → `{categories: [{name, bit}]}`. A mod's categories are stored as tags AND encoded as a `flags` bitmask (sum of bits) on its `.tmod`; test membership with `flags & bit`. The build/read tools auto-encode/decode this (see Mods section above: `flags` + `categories`). Mod **hashes are unique per creator**: a release whose exact artifact (sha256) is already published by another creator is rejected (anti-reupload). An **uploaded-on-behalf** mod is held to a stricter, *global* bar: its artifact must not already exist anywhere on the hub at all (anti-duplicate for shared re-uploads). ### Mods Hub - browse/share/develop mods (website feature; not in this API reference) A stored, git-like hub for sharing & versioning mods (vs the stateless build/read tools above), at **trove.aallyn.net/mods**; browse + download are public, developing needs a Discord (site) login. A *project* has banners/previews, **forking** + **inspired-by attribution**, and one of two **modes**: - **files** - full versioned workflow (git commits/branches/clone) + *releases*. A release is compiled server-side from a commit into a selectable **format** (`.tmod` or `.zip`), or an uploaded prebuilt build. - **releases** - releases-only: the modder just uploads already-built `.tmod`/`.zip` files (no file history). A user can also **upload a mod on someone else's behalf** ("This mod was made by someone else" in the create form): the uploader owns/manages it but names the real creator, shown as "Uploaded by · Created by " with an **Uploaded** badge. These are forced releases-only, auto-public, NOT claimable, and their uploaded builds must be globally unique (the same artifact can't already exist anywhere on the hub). They're kept deliberately **bare** - since the uploader isn't the author, an uploaded mod carries **no owner links (Discord/website), no donation buttons, and no inspiration credit**, and can't be forked or credited as inspiration by others. Files-mode projects can set **source private** (`source_visibility`) - keeps version history + git for the owner but exposes only the releases publicly (an internal-tool mode; hides the files view + blocks cloning). **Branches are variants**: each release carries its branch; the UI groups releases per branch (latest of each surfaced), and the owner can hide chosen variants' releases from the public. A **source-locked** mod (private source / releases-only) can't be **forked** - it can only be credited as **inspiration**. The website-internal hub surface (`/v1/mods/hub/*` + the same-origin `/site/mods/*` proxies) is **intentionally not in this public reference** - it's driven by the website. The documented, app-facing read API is the **Mods catalog** section above (`/v1/mods`, `/v1/mods/{slug}`, `/v1/mods/popular`, `/v1/mods/lookup`). **git access.** Each mod is a real git repo: ``git clone https://api.aallyn.net/git/mods/.git`` (public & unlisted clone anonymously; drafts need the owner). To push, generate a **git access token** in the User Dashboard and use it as the git *password* (any username) - a web "Commit files" and a ``git push`` share one history. ### Modpacks catalog (`mods:read`, tokenless) - curated bundles of hub mods At **trove.aallyn.net/modpacks**: a *modpack* groups several published hub mods so a player can grab them all at once. It stores only **references** to mods (no mod content of its own) and rides the same master toggle as the hub. Browse + download are public; creating one needs a Discord (site) login. - **No releases, but variants** - named spin-offs (e.g. "Full"/"Lite"), each an ordered list of mod **entries**; one is the default. Each entry references a mod + the mod **variant** (branch) to pull from, and a **version lock**: off by default (tracks the mod's latest published `.tmod` on that branch) or pinned to a specific release tag. The detail lists every variant + each mod it bundles + the version it resolves to. - **Download** builds the artifact on the fly: a **`.zip`** (each mod's `.tmod` + a `modpack.json` manifest) or a **`.tpack`** (the same container format as a `.tmod` - `build_tpack`, same `modLoader` marker - packing each mod's `.tmod`; Trove has no native `.tpack`, it's our own `.tmod`-compatible format, told apart by the extension + `manifest` header property). Each packed `.tmod` keeps its exact `.tmod` filename (case preserved) - Trove validates the filename against the mod's header `title`. App-facing read API (absolute image / page / download URLs, like the Mods catalog): - `GET /v1/modpacks` - list public modpacks (cards). Query `q` (case-insensitive substring over title/summary/tags), `tag`, `author`, `sort` ∈ `recent|downloads|stars|new|title`, `limit` (≤100), `offset`. → `{items, count, total, limit, offset}`. A card = `{slug, handle, title, summary, tags, author, banner_url, preview_urls[], download_count, star_count, variant_count, mod_count, default_variant, page_url, created_at, updated_at}`. - `GET /v1/modpacks/{handle}/{slug}` - full detail (404 if not public): the card fields plus `description`, `warnings`, `discord_url`, `website_url`, `donation_urls[]`, and `variants[]` = `{name, label, mod_count, available_count, download_url, zip_url, mods: [{handle, slug, title, author, variant, version, version_locked, available, page_url, author_url}]}` (`version` is the build it resolves to - latest unless locked; `available:false` flags a mod with no current build; `author_url` links to the mod author's page). - `GET /v1/modpacks/{handle}/{slug}/download?variant=&format=tpack|zip` - download a variant (defaults to the pack's default variant, `.tpack`). Built on the fly so unlocked mods resolve to their latest build. - `GET /v1/modpacks/for-mod/{handle}/{slug}` - the **reverse link**: public modpacks that include a given mod (by the mod's `/`). → `{items: [, …], count}`. Lets a mod page surface the packs bundling it. The website-internal surface (`/v1/modpacks/hub/*` + same-origin `/site/modpacks/*`) is website-driven and **not in this reference**; the documented app-facing API is the three endpoints above. ### Updates (`updates:read`) - archived game files (latest version) Kiwi mirrors Trove's update CDN and stores each version's files content-addressed (deduped). Browse them: - `GET /v1/updates/branches` → `{ items: [{ branch, current_version, current_ordinal, last_probe_at, status, file_count }], count }` (branches: `live-us`, `pts`) - `GET /v1/updates/{branch}/versions[?limit=&offset=]` → `{ items: [{ ordinal, version_tag, captured_at, files_added, files_modified, files_removed, bytes_added }], count, total }` - `GET /v1/updates/{branch}/changes[?version=&ordinal=&type=&limit=&offset=]` → `{ branch, ordinal, version_tag, entries: [{ path, type, content_sha256, size }], count, total, files_added, files_modified, files_removed }` - the per-file diff a version introduced (`type` ∈ `added,modified,removed`). Pin the version by `version` tag or `ordinal`; omit both for the latest. `type` filters to one kind. - `GET /v1/updates/{branch}/tree[?prefix=]` → `{ branch, prefix, entries: [{ name, path, is_dir, file_count, size }], count }` - one directory level (prefix `prefabs/` lists its children); empty prefix = root - `GET /v1/updates/{branch}/file/meta?path=` → `{ path, content_sha256, size, archive, archive_index }` - `GET /v1/updates/{branch}/file?path=` → the raw file bytes (`application/octet-stream`, streamed). **Public, tokenless** (so files can be linked directly); the other updates endpoints still need `updates:read`. Works for loose and TFA-extracted files alike. - `GET /v1/updates/{branch}/file/view?path=` → `{ branch, path, size, content_sha256, viewable, reason, truncated, text }` - the in-browser preview payload. `viewable:true` with `text` = the UTF-8 content when the file is ≤512 KB and decodes cleanly (no NUL byte); otherwise `viewable:false` with `reason` ∈ `too_large`/`binary`/`missing` so the client offers the raw `/file` download instead of rendering bytes. ### Codexes (`codexes:read`) - structured game data parsed from the archive Nine typed datasets parsed from `prefabs/*.binfab`, names resolved via the `languages/` locale tables. Stored in Postgres (`codex_entry`, one row per `(branch, path)`; the two branches `live-us`/`pts` are the two "modes"). Re-indexed after each archive sync (full build once, then only the changed prefabs; `content_sha256` ties each row to its source file). All default to branch `live-us` (`?branch=pts` for PTS). Each entry has identity (name/category/description/tradable) + decoded collectible bonuses: `mastery` (normal, from `meta/multipliers.binfab`), `mastery_geode` (geode-mode, from `meta/geode_multipliers.binfab`), `power_rank`, and a `data` blob of numeric stat bonuses, visible/hidden ability refs, geode-companion upgrade-tree levels, recipe catalogue/providers, and style identity. - `GET /v1/codexes/types[?branch=]` → `{ branch, items: [{ type, count }], count }` - types present: `ally`, `mount`, `dragon`, `memento`, `style`, `recipe`, `item`, `fish`, `badge` - `GET /v1/codexes/search[?branch=&q=&type=&category=&tradable=&sort=&limit=&offset=]` → `{ branch, type, query, items: [Entry], count, total }` - cross-type search (all filters optional + ANDed; each `Entry` has its own `type`). `q` = name/description substring; `sort` ∈ `name,-name,category,-category,mastery,-mastery,mastery_geode,-mastery_geode,power_rank,-power_rank,indexed_at,-indexed_at` - `GET /v1/codexes/{type}[?branch=&search=&category=&tradable=&sort=&limit=&offset=]` → `{ branch, type, items: [Entry], count, total }` - `search` matches name OR description; `category` is exact; `tradable` is a bool - `GET /v1/codexes/{type}/categories[?branch=]` → `{ branch, type, items: [{ category, count }], count }` - distinct categories for filter dropdowns - `GET /v1/codexes/{type}/entry?path=[&branch=]` → one `Entry` by its source prefab path - `Entry` = `{ type, path, name, category, description, tradable, mastery, mastery_geode, power_rank, blueprint, data, indexed_at }` (`path` is the stable id; `mastery`/`mastery_geode` = collectible mastery, null for non-collectibles; `power_rank` null when absent). `data` carries: `stats` (numeric bonuses: `{ stat: $Stat_…, stat_name (resolved display name), operation, amount, value, is_percent, label, slot, slot_name, component, level }`), `abilities` (`{ ref, hidden, name, key?, description? (resolved from the locale tables) }` - hidden refs are evidence, not displayed bonuses), for **recipes** `recipe` (`{ output: { path, name, amount }, ingredients: [{ path, name, amount }], requirements: [str], mastery, mastery_source, mastery_prefab_byte?, in_catalogue, catalogue_order?, providers? }` - names resolved by reading the referenced item prefabs; recipe `mastery` is row-local: an exact `meta/multipliers.binfab` row overrides (`mastery_source: "multipliers"`), else the trusted structural byte in the recipe prefab (`"prefab"`, raw value in `mastery_prefab_byte`), else `null` with `mastery_source: "review"` when no trusted/agreeing byte is present. `in_catalogue` = membership in `collection_recipe.binfab` (`catalogue_order` = its source position); `false` marks a source-only recipe. `providers` = the benches/professions a recipe is craftable at (`[{ provider, provider_path, lane }]`, inverted from the crafting-station + profession prefabs that list the recipe - e.g. `placeable/crafting/workbench_chaos_interactive`, `professions/gearcrafting`; lane ∈ bench/profession/vendor/guide/interactive)), for **styles** (hats/faces/weapons/banners, under `style/`) `style` (`{ source_id, equipment_ref (the equipment id it restyles), resolved_id, confidence, family }`; style `mastery`/`mastery_geode` resolve via the equipment id's multipliers row, `null`/review-only when unbacked), and for geode companions `geode_companion` (`{ upgrade_tree, rarity, levels: [{ level, stats, abilities }] }`). The `$…` localization keys (stat/slot names, ability descriptions) are resolved against the archived `languages/` tables at index time. ### BTT (`btt:read`) - BetterTroveTools releases (drives in-app update checks) Relayed every 30 min from the GitHub releases of `AallynReed/BetterTroveTools` and cached in Mongo. Two channels: `release` (`prerelease=False`) and `beta` (`prerelease=True`). Three platforms detected by asset extension: `windows` (`.msi`/`.exe`, msi prioritized), `linux` (`.AppImage`/`.deb`/`.rpm`/`.tar.gz`), `android` (`.apk`). - `GET /v1/btt/releases[?channel=release|beta&limit=50&offset=0]` → `{ channel, items: [BttRelease], count, total }`, BttRelease = `{ release_id, tag_name, name, body, html_url, prerelease, channel, published_at, fetched_at, assets:[{name, url, size, content_type, download_count}] }`. - `GET /v1/btt/latest?channel=release` → `{ channel, platforms: { windows: PlatformLatest|null, linux: …, android: … } }`, PlatformLatest = `{ platform, release: BttReleaseMeta, assets:[Asset] }` (BttReleaseMeta = BttRelease minus `assets`). Each platform walks the channel newest-first to the first release that ships an asset for it - so a release without (say) a Windows build doesn't suppress Windows updates. - `GET /v1/btt/latest/{platform}?channel=release` → one `PlatformLatest`. 404 if no release on that channel carries an asset for the platform yet. - `GET /v1/btt/check?installed=&platform=&channel=release` → `{ installed, channel, platform, update_available: bool, comparable: bool, latest: PlatformLatest|null }`. Server-side version comparison - the client just reads `update_available`. Accepts `v1.2.3` / `1.2.3` / `1.2.3-beta.1`; release > prerelease at the same numeric core. `comparable: false` means a tag couldn't be parsed (treat carefully). `latest: null` when no release on that channel ships an asset for the platform yet (returns false; doesn't 404). - `GET /v1/btt/changelog[?limit_groups=&commits_per_group=]` → `{ repo, groups: [{version, commits: [{sha, short_sha, message, type, url}]}], rate_limited, fetched_at }`. Mirrors BTT's "Show changelog" button: commits from `GET /commits?per_page=100` grouped by git tag (`GET /tags`), newest first; commits since the latest tag live under `"Unreleased"`. `type` is the parsed conventional-commit prefix (feat/fix/docs/…) lowercased, or null. Cached server-side (30-min cadence) so users don't trip GitHub's 60/hr unauth rate limit. Returns an empty payload (not 404) before the first refresh completes. ### Leaderboards (`leaderboards:read`) - Trove in-game leaderboards Ingested from an external bot that periodically reads the game's `LeaderBot.cfg` file and POSTs the raw text to `/v1/leaderboards/insert`. The bot anchors each dump to the most recent 11:00 UTC reset (Trove's daily-reset hour); reads filter by that exact anchor. Entries older than 90 days are pruned on each insert (boards/metadata are kept forever). The `/insert` endpoint is **master-only** - it requires an API token belonging to a superuser account; the read endpoints take any token carrying `leaderboards:read`. - `GET /v1/leaderboards/timestamps[?limit=60]` → `{ items: [], count }` - recent dump anchors, newest first. Each value is a unix-seconds timestamp at 11:00 UTC. - `GET /v1/leaderboards?created_at=` → `{ created_at, items: [Board], count }`, Board = `{ uuid, name_id, name, category_id, category, contest_type, reset_kind, player_board }`. `contest_type` is `"daily"`/`"weekly"`/`null` for THIS anchor; `reset_kind` describes the BOARD's cycle (`daily`/`weekly`/`default`); `player_board=false` for server-tally boards (1100, 21012). `created_at` may also be the 00:00 UTC alias for that day (normalized to 11:00). - `GET /v1/leaderboards/{uuid}` → one `Board` plus its full `contests` list (`[{time, type}]`). 404 if no such board has ever been seen. - `GET /v1/leaderboards/{uuid}/entries?created_at=[&limit=100&offset=0]` → `{ uuid, created_at, items: [Entry], count, total, comparison }`. Entry = `{ rank, player_name, score, prev_rank, prev_score, rank_delta, score_delta, is_new }`. The deltas are day-over-day movement vs the previous trove-day's latest snapshot: `rank_delta = prev_rank - rank` (+ve = climbed), `score_delta = score - prev_score` (+ve = gained); `is_new=true` for a player with no prior-day position. Populated only when `comparison.comparable` is true - the board must NOT have reset between the two snapshots, so daily boards never carry day-over-day deltas, weekly boards skip across the Monday 11:00 UTC reset, and lifetime boards always compare. `comparison = { comparable, prev_anchor, reason }` (reason: `ok`/`no_prior_snapshot`/`crossed_reset`). `total` is the full row count (independent of the window). - `GET /v1/leaderboards/players/{player_name}/history[?uuid=&limit=50]` → `{ player_name, items: [{ rank, player_name, score, leaderboard, created_at }], count }` - recent appearances of a player across boards (optionally filtered to one board), newest first. **Case-insensitive** exact match on `player_name` (Postgres `lower(name)`-indexed `player` lookup). (The showcase site's `/site/…` proxy of this also attaches the same `rank_delta`/`score_delta` per row; the public endpoint leaves those null.) - `GET /v1/leaderboards/players/{player_name}/profile` → `{ player_name, verified, summary: { boards_appeared, appearances, best_rank, best_rank_board_uuid, best_rank_board_name, latest_anchor }, recent: [{ player_name, leaderboard, board_name, rank, score, created_at, prev_rank, prev_score, rank_delta, score_delta, is_new }] }` - **tokenless** public profile aggregate (recent appearances enriched with board names + day-over-day deltas, plus a summary). `verified=true` when a site account has claimed AND been master-approved for this name (ties to the manual claim flow). Unknown names return an empty `recent`, not a 404. Powers `/player/` and a future bot `/rank` deep-link. - `GET /v1/leaderboards/{uuid}/health` → `{ uuid, name, category, reset_kind, anchor, prev_anchor, comparable, comparison_reason, population, sample_size, leader_share, p1_pn_ratio, gini, turnover_rate, new_entrants, median_score_gain, median_score_gain_pct }` - board health from the latest snapshot vs the previous trove-day. **Competitiveness** (always present): `leader_share` = #1 score / Σ(top-N), `p1_pn_ratio` = #1 / last-in-sample, `gini` = 0 (even) … →1 (one player dominates). **Turnover + score inflation** (`turnover_rate`/`new_entrants`/`median_score_gain[_pct]`) are day-over-day and **null when not comparable** (`comparable=false`: a reset crossed, or no prior snapshot - same comparability rule as `/entries`). - `GET /v1/leaderboards/{uuid}/history[?days=7&top=5]` and `GET /v1/leaderboards/players/{player_name}/series[?days=7]` → chart-ready timeseries. Series points carry `{ created_at, rank, score, synthetic }`. **Reset-zero injection**: on daily/weekly boards the server inserts synthetic points at every reset moment falling between two real captures - one "hold-flat" point at `R − 1s` carrying the pre-reset score, plus a `score=0` point at the reset `R` itself - so the chart shows an honest cliff drop instead of a smoothed descent. Lifetime boards (`reset_kind` ∈ `default`/`none`) skip injection. Renderers should keep synthetic points in the line path but suppress hover dots / tooltips on them (`synthetic=true`). Resets are at midnight UTC−11 (i.e. **11:00 UTC**); weekly boards reset on Monday at that time. Real captures always carry `synthetic=false`. **Fast path**: `/{uuid}/history` only queries the top-`top` players' rows (three indexed Postgres queries - distinct anchors, the latest anchor's top-N, then those players' trajectories - NOT the whole board's window), and the result is served from a Redis read-through cache whose default `7d/top=5` payload the warmer pre-computes for EVERY board on each ingest, so the per-board chart paints instantly. - `GET /v1/leaderboards/cheaters` → `{ players: [{ player_name, confidence, leaderboards: [{ uuid, name, category, contest_type, rank, score, evidence: [{ type, summary, measurements, confidence }] }] }], clusters: [{ stem, label, method, corroborated_by, member_count, members, members_truncated, board_count, boards: [{ uuid, name, category, contest_type, members, member_names, score_min, score_max, spread, rank_min, rank_max, matching_hours, avg_hourly_gain }], confidence, summary, measurements }], clusters_boards_scanned, computed_at, anchor, method, config, total_flagged, boards_analyzed, boards_excluded, analyzed_boards: [{ uuid, name, category, reset_kind, contest_type, entries }], excluded_boards: [{ uuid, name, category, reset_kind, contest_type, reason: "admin_excluded"|"below_min_size", entries? }] }` - **tokenless**. The `analyzed_boards` / `excluded_boards` arrays detail exactly which boards the run scanned vs skipped (sorted by category+name); `reset_kind` reflects the admin override (so a board pinned to `none` shows as lifetime regardless of the hardcoded mapping). `excluded_boards.reason` distinguishes operator opt-out from below-min-size skips. **Counts are derived from the lists** - `boards_analyzed = len(analyzed_boards)` and `boards_excluded = count of reason="admin_excluded"`. Flags statistically anomalous play on the latest captured anchor. Runs three independent per-player checks (in `players`) plus a group-shaped alt-cluster check (in `clusters`). **Score outlier** - `log10`-transformed Modified Z-score against the **elite cohort** (top 5% or top 50, whichever larger) of the board, NOT the full population. Trove leaderboards are heavy-tailed (top players score 10-100× the median), so a naive full-board MAD-Z falsely flags every dedicated top player; using the cohort + log-transform restricts the check to "anomalous *among the top players*". Default threshold: 5.0. Capped boards (top-5 scores tied) are skipped entirely. Only the top half of the cohort is checked. **Rank-gap** - score drop from rank N to N+1 vs the typical between-rank gap on the same board (10× cutoff). **Velocity** - score-gain rate vs the board's peer p95 rate, using the player's previous historical capture (10× cutoff). **Weekly uptime** (`sustained_velocity` evidence) - a WEEK-long pattern, not just the last hour: a player whose score rose in ≥ `cheaters_weekly_uptime_fraction` (default 0.85) of the captures since the weekly reset. No human plays 85%+ of every hour for days - that's a no-sleep bot, which the per-hour velocity check can't see (each hour looks normal alone). Only applies once ≥48 captures have elapsed; computed from the same week data the cluster pass loads (no extra queries). **Reset-cycle aware**: for daily/weekly boards (cadence on the `reset_kind` field) the previous-anchor lookup is bounded to the same reset cycle - comparing a score across a reset boundary would otherwise produce spurious flags as "starting fresh and grinding hard" looks like an extreme velocity vs a baseline that mixes pre- and post-reset rates. **Alt-cluster** (the only *group*-shaped check; emitted in the separate `clusters` array, NOT as per-player evidence) - families of similarly-named accounts (`anana1 … anana20`, `Aan_1 … Aan_7`) sitting at near-identical scores. Names are grouped by a normalised **stem** (trailing digits/separators stripped, then leet/number-substitution folded so `Dr4g0n`≈`dragon`) plus a small **edit-distance** merge for typo'd variants; per board the **densest near-score subset** (≥ `cheaters_cluster_min_size` accounts within `cheaters_cluster_score_band_pct`) is kept, then the same family is merged across boards. None of the per-player checks can see this - each alt is unremarkable alone; only the family is anomalous. Cluster `confidence` (capped at 0.95) folds three signals: **closeness** (tightest board's spread vs the band), **size** (ramps to full at `cheaters_cluster_size_full` accounts), and **board count** (`1 − 0.5^boards`); the `measurements` dict exposes each sub-term. Every cluster carries a `method`: **co_movement** is the PRIMARY, name-agnostic signal - accounts whose hourly score GAINS move in lockstep across the captures since the last weekly reset. Per board it keeps only the top-percentile gainers each hour (`cheaters_comovement_gain_percentile`, so a crowd at a common rate drops out), bands each hour's survivors into tolerance groups (`cheaters_comovement_gain_tolerance_pct`, a sorted sweep so a hair's-width gain difference can't straddle a fixed-bucket edge and split a real ring), and links two accounts only when their shared (hour, band) cells are BOTH ≥ `cheaters_comovement_min_matching_hours` AND ≥ `cheaters_comovement_min_match_ratio` of the rarer account's active hours (a fraction, not an absolute count - so a few coincidental matches over a long week can't chain the hardcore-grinder crowd into one giant false ring). Each connected group is then peeled to its dense core (`cheaters_comovement_min_density`, so a loose transitive chain collapses while a near-clique ring survives) before merging across boards by member overlap; co-movement confidence (capped 0.97) folds matching-hour count + group size, and those boards carry `matching_hours`/`avg_hourly_gain` instead of the score-range fields. **schedule** is a name-agnostic SCHEDULE-correlation producer: accounts active/idle in the same hours since the weekly reset (Jaccard of active-hour sets ≥ `cheaters_schedule_min_similarity`, ≥ `cheaters_schedule_min_active_hours` active hours, "always-on" accounts excluded as non-distinctive) - catches alts that grind DIFFERENT content but play together, which co-movement (gain-magnitude) misses; weak alone, so low base confidence unless corroborated. **name_stem** is the name+near-score method above; **both** marks a group flagged by several signals. **FUSION**: the producers' clusters (name_stem + co_movement + schedule) are merged by member overlap, every signal is re-evaluated on each merged group (co-movement, schedule cohesion, shared name stem, board-footprint Jaccard ≥ `cheaters_footprint_min_jaccard`), and the agreeing signals are listed in `corroborated_by` - confidence is the strongest single-signal value PLUS `cheaters_fusion_corroboration_bonus` per EXTRA independent signal (capped 0.98). The whole point: a group flagged by co-movement AND schedule AND name is far more certain than one. The history pass is windowed to the current week, candidate-limited (top `cheaters_comovement_candidate_top_n` by rank per board), run off the event loop, and throttled to `cheaters_comovement_recompute_seconds` (cached by weekly-window, reused across warm cycles). Each per-player evidence carries a per-check `confidence` (sigmoid on `magnitude / threshold`, 0.5 at the threshold, capped by per-check ceilings reflecting empirical false-positive rates: velocity=0.99, rank_gap=0.85, score_outlier=0.60); each player carries an overall `confidence` aggregated as max-within-board, noisy-OR across boards, with a diversity cap (if only one check type fires, overall confidence is limited to that type's ceiling - so a player anomalous-by-static-rank on 10 boards but normal-velocity still caps at 0.60 because score_outlier alone is too noisy to claim certainty without corroboration). Server-side cached in-process for `cheaters_cache_ttl_seconds` (default 1800s) AND pre-computed by a background warmer at app boot + on the same TTL cadence, so callers never wait for the per-board scan. All thresholds (`cheaters_z_threshold`, `cheaters_velocity_multiplier`, `cheaters_elite_cohort_pct`, `cheaters_min_board_size`), the alt-cluster knobs (`cheaters_cluster_min_size`, `cheaters_cluster_score_band_pct`, `cheaters_cluster_max_edit_distance`, `cheaters_cluster_size_full`), the co-movement knobs (`cheaters_comovement_candidate_top_n` [0 disables], `cheaters_comovement_min_hourly_gain`, `cheaters_comovement_gain_percentile`, `cheaters_comovement_gain_tolerance_pct`, `cheaters_comovement_min_matching_hours`, `cheaters_comovement_min_match_ratio`, `cheaters_comovement_min_density`, `cheaters_comovement_min_group_size`, `cheaters_comovement_max_cell_accounts`, `cheaters_comovement_recompute_seconds`), the schedule + fusion knobs (`cheaters_schedule_min_active_hours`, `cheaters_schedule_min_similarity`, `cheaters_fusion_corroboration_bonus`, `cheaters_footprint_min_jaccard`), the weekly-uptime knob (`cheaters_weekly_uptime_fraction`), the TTL, and the two INDEPENDENT board blacklists - `cheaters_excluded_board_uuids` (per-player checks) and `cheaters_cluster_excluded_board_uuids` (alt-cluster check) - are runtime-tunable from the master admin panel. `clusters_boards_scanned` reports how many boards the cluster pass examined (its own blacklist + min-size, distinct from `boards_analyzed`). The response echoes the active `config` and `boards_excluded` count so a flag is reproducible. - `GET /v1/leaderboards/records` → `{ trove_mastery, geode_mastery, power_rank }` - **tokenless**. The current in-game *ceiling* for each stat, read off the rank-1 holder of the relevant **lifetime** board (these never reset, so their #1 is an all-time record). `trove_mastery` (board 1) and `geode_mastery` (board 20) are `{ points, level, points_into_level, points_to_next_level, player_name, anchor }` - the boards store a running Mastery **points** total, and `level` runs that total through the in-game points→level curve (levels 2-5 cost 25pts, 6-10 cost 50, 11-20 cost 75, 21-300 cost 100, 301+ cost `150 + ceil((level-300)*0.5)`), with `points_into_level`/`points_to_next_level` showing progress into the current level. **Geode is soft-capped at level 100 in-game**, so its `level` is clamped there and it carries extra fields `{ level_cap: 100, uncapped_level, capped }` - `uncapped_level` is what the points would reach without the cap (e.g. `100` vs a true `143`), `capped=true` when the two differ. `power_rank` is `{ value, board_uuid, player_name, anchor }` - the single highest Power Rank across all 17 per-class boards (1000-1016); `value` is stored directly (no level conversion) and `board_uuid` says which class board it came from. Any of the three is `null` only if that board has never been ingested. Refreshes at most once per daily dump (11:00 UTC); `Cache-Control: public, max-age=900`. - `POST /v1/leaderboards/insert[?timestamp=]` (multipart `file`) - **master only**. Submit the raw cfg text as a multipart `file` field. Idempotent for a given anchor: re-running the same dump on the same timestamp converges. `timestamp` is optional and only used for back-fills; omit it for a normal scrape. Returns **202 Accepted** immediately with `{ accepted, bytes, message }` - the parse + persist (sub-second `COPY` load on a full dump) runs in the BACKGROUND so the bot's client isn't held open and timed out; the resulting `{ boards, entries, cleared_before_insert, created_at }` counts (or any parse/DB error, as `success:false`) are written to the master ingest log when processing finishes. **Full-history storage in PostgreSQL** (a dedicated DB, separate from the app's Mongo): the `entry` table is RANGE-partitioned by anchor (one partition per trove-day, 11:00-UTC boundary), so old data is just old partitions - no hot/cold collection split. Partitioned-parent indexes `(board_uuid, anchor, rank)` and `(player_id, anchor)` keep both board-at-time and player-over-time reads O(index seek) at any age; player names are normalised into a `player` dimension table. `leaderboards_hot_retention_days` (default 3 days; runtime-tunable from the master admin panel) now just controls how many recent days the warmer pre-warms / the page surfaces. Use `/timestamps?limit=` to enumerate ALL stored anchors (deduped). **Archive rate limit**: queries with a `?created_at=` older than `leaderboards_archive_query_threshold_days` (default 3 - bot captures hourly, so a 3-day window already covers ~72 anchors of "recent" data) pay a SECOND, tighter per-token bucket (default 10 req/min) on top of the standard cap - surfaced via `X-RateLimit-Archive-Limit` / `-Remaining` / `-Reset` response headers. Applies to `/v1/leaderboards?created_at=` and `/v1/leaderboards/{uuid}/entries?created_at=`; queries within the threshold pay only the standard cap. The threshold is runtime-tunable from the master admin panel. **Ingest cooldown**: API-token submissions are additionally capped at `ingest_cooldown_max` per `ingest_cooldown_window_seconds` (default 1 per 300s) per token per endpoint - exceeding returns 429 with `Retry-After`. Session-JWT calls (portal "Manual cfg ingest") bypass this so master back-fills aren't blocked. ### Activity (`activity:read`) - free/public Trove player-activity estimates Lower-bound active-player counts derived from the leaderboard captures (distinct top-N players whose score rose between captures). **Public / tokenless** - a token carrying `activity:read` just earns the wider per-token budget. Powers the showcase `/activity` ("Player Activity") page via the same-origin `/site` proxies, but documented + stable for third-party dashboards / wikis too. All cached for `cheaters_cache_ttl_seconds`. (These replaced the old `/v1/misc/activity*` and `/v1/leaderboards/activity*` copies.) - `GET /v1/activity/current` → `{ estimate, estimate_24h, estimate_7d, window_24h_start, window_7d_start, span_24h_hours, span_7d_hours, window_start, window_end, duration_hours, by_board:[{uuid,name,category,active_players}], boards_analyzed, methodology, computed_at }` - lower-bound distinct active players between the two most recent captures: a player whose score ROSE on any board vs the previous capture, or who newly appears with a non-zero score (`score > prev`, absent-previous treated as 0). No score ceilings, no board filters; a reset drop isn't a rise. `estimate_24h` / `estimate_7d` are distinct-active rollups over the last 24h / 7d - the distinct UNION of each capture's active set in the period. Each capture's set is MATERIALIZED once in an `activity_active` table, so the rollups are an indexed `COUNT(DISTINCT)` over the windows in range - the true union, MONOTONIC (`7d ⊇ 24h ⊇ 1h`), no re-scan. `null` until an earlier anchor exists; `span_*_hours` = the window actually covered. `estimate` is `null` until two captures exist. - `GET /v1/activity/history?days=7` (1–30) → `{ days, window_start, window_end, methodology, points:[{window_end, window_start, duration_hours, estimate, estimate_per_hour}] }` - time-series, one point per consecutive capture pair. `estimate_per_hour = estimate / duration_hours` is the value a chart should plot (a missed capture makes a window span 2-3h and inflates the raw `estimate`; the per-hour rate normalises it). Persisted per ingest, survives restarts. - `GET /v1/activity/series?period=7d` (period ∈ `1d`/`7d`/`1m` - longer ranges were removed) → `{ period, bucket_seconds, window_start, window_end, points:[{t, active, peak, samples}], peak:{t, active}|null, average, latest, methodology }` - downsampled activity-level series; each point is a fixed time bucket sized to the period (hourly for `1d`/`7d`, daily for `1m`), `active` = AVERAGE active-players-per-hour in the bucket and `peak` its busiest hour. `peak`/`average`/`latest` summarise the period. Backs the `/activity` page charts. - `POST /v1/activity/backfill[?total_days=400&chunk_days=14&force=false&reset=false]` - **master only**. Seeds the `/series` history from the stored Postgres captures so the multi-period charts have data predating the hourly forward-fill. `total_days=0` rebuilds ALL stored history (no lower bound); else the trailing N days. Returns **202** + runs in the BACKGROUND, STREAMING one capture's entries at a time (a 1-deep sliding window over consecutive anchor pairs), so peak memory stays at ~one capture regardless of range - the naive "load the whole range into one dict" approach OOMs on dense hourly data. Coverage capped by how far the stored entries reach; `force=false` skips already-computed windows. (`chunk_days` is accepted for compatibility but unused now that the load streams.) The per-hour series skips windows that span a missed capture; the gap cutoff is derived from the **median capture cadence** (not a hardcoded 1h), so a non-hourly or jittery archive isn't dropped wholesale - the backfill logs `median_interval_hours` / `gap_threshold_hours` / `gap_skipped` for diagnosis. **`reset=true` is destructive**: it first DELETES every stored estimate, then recomputes the whole range from scratch (implies `force`) - use it to flush miscalculations from older runs. The `activity_estimate` table is fully derived data, so nothing irreplaceable is lost; the charts just read empty until the rebuild lands (`reset_deleted` count is logged). ### Class activity (`activity:read`) - free/public per-class Trove activity Per-Trove-class version of player activity, from the **Effort (`4000+i`)** leaderboards: `class_index = board_uuid % 1000` maps to classes.json order (0=Bard … 17=Vanguardian). Paragon (`5000+i`) is **excluded as ambiguous** — neither counted nor filtered on. A class is "active" in a window when a player's score rose (or first appears) on its Effort board (this score-rose measure drives the time-series `/series`; the donut `/current` instead counts players simply *present* on the Effort board in the latest snapshot — see each endpoint below). Effort boards reset weekly (Mon 11:00 UTC) so windows crossing the reset are unmeasurable (no point). **Two views in every payload:** RAW counts everyone; CLEAN (the `/class-activity` page default, "established", `*_clean` fields) keeps only players who, snapshot at the window end, clear BOTH per-class floors — Power Rank (`1000+i` board) ≥ `class_activity_power_rank_threshold` (default **25000**) and Effort (`4000+i`) ≥ `class_activity_effort_threshold` (default **50**) — both master-tunable runtime settings (set either to 0 to drop that gate); this filters new characters + alts. `clean` is `null` where unmeasurable (no Power Rank snapshot). Same `activity:read` scope, same `/site/leaderboards/class-activity/*` proxy family, powers the `/class-activity` page (multi-line chart with an Established/All toggle + player-share donut). - `GET /v1/class-activity/current` → `{ window_start, window_end, duration_hours, total_active, total_active_clean, total_effort_added, total_effort_added_clean, power_rank_threshold, effort_threshold, classes:[{class_index, name, icon, active_players, share, active_players_clean, share_clean, effort_added, effort_added_clean}], methodology, computed_at }` - **a direct headcount of the LATEST snapshot, NOT the activity pipeline**: per class, the players *present* on its Effort board at the newest capture (Paragon excluded; no score-rose step), for BOTH views (`share`/`share_clean` each sum to 1 over their measurable classes). `effort_added`/`effort_added_clean` (+ the `total_*` rollups) are the Effort added to each board in the **latest hour** (this capture vs the previous; Σ positive per-player gains, per view; `null` when there's no previous capture or the pair crosses the weekly reset). `share` is share-of-players: a player on N classes counts in each, NOT distinct players. `*_clean` is `null` where the clean view is unmeasurable (no Power Rank board in the snapshot). `window_start`=`window_end`=snapshot anchor; `duration_hours` is null. `classes` sorted by the clean view desc (None last). (Powers the page's player-share donut; `/series` below stays activity-based.) - `GET /v1/class-activity/series?period=7d` (period ∈ `1d`…`all`) → `{ period, bucket_seconds, window_start, window_end, power_rank_threshold, effort_threshold, buckets:[t,…], classes:[{class_index, name, icon, values:[float|null,…], values_clean:[float|null,…]}], methodology }` - per-class bucketed series with a SHARED x-axis (`buckets`); each class's `values` (raw) and `values_clean` (PR+Effort-filtered) align to it (avg active/hr per bucket on the Effort board, `null` where that view had no measurable window in the bucket). Same bucket sizing as `/v1/activity/series`. - `POST /v1/class-activity/backfill[?total_days=400&force=false&reset=false]` - **master only**. Seeds the per-class history (raw + clean; `class_activity_estimate` table) from stored captures; same memory-safe streaming as the activity backfill, loading the 18 Effort boards + 18 Power Rank boards per anchor. `total_days=0` = all history; `reset=true` wipes first (implies force). **202** + background. Run after changing either clean-view threshold to apply it across history (the warmer applies it to the latest window each capture automatically). ### Giveaways (`giveaways:read`) - free/public Trove community giveaways **Public / tokenless** - a token carrying `giveaways:read` just earns the wider per-token budget. The showcase `/giveaways` page is the main consumer (via the same-origin `/site/giveaways` proxy); this is the documented, stable API surface. (Entering a giveaway is a signed-in site-user action, not part of the public API.) - `GET /v1/giveaways/ongoing` → `[{ id, title, description, prize_name, status, starts_at, ends_at, entry_count, winner_username }]` - currently-OPEN giveaways (accepting entries), soonest to end first. `status` is `"open"` for all of these; `winner_username` is always `null` (winners only appear after a draw) and the prize code is NEVER exposed. Use `entry_count` to compute odds (`1 / entry_count`) and `ends_at` for the countdown. Cached 30s. - `GET /v1/giveaways/upcoming` → same shape - SCHEDULED giveaways not yet open, soonest-starting first. `status` is `"scheduled"`; `entry_count` is 0 until `starts_at`. Cached 30s. - `GET /v1/giveaways/ended[?days=7]` (days 1–30) → same shape - giveaways that ENDED in the last `days` days (default 7), most-recently-ended first. `status` is `"drawn"` (had a winner - see `winner_username`) or `"closed"` (no entrants); cancelled giveaways are excluded. Cached 30s. ### Market (`market:read`) - Trove marketplace listings Ingested from the same bot that submits leaderboards. Every hour or so the bot reads the game's `GrainusMod.cfg` and POSTs the raw text to `/v1/market/insert`. Each line is `;;;;` with the UUID v1 of the in-game listing. Listings are stored in **Postgres** (`market_listing` table, the same DB as leaderboards): the UUID is the primary key, its timestamp decodes into `created_at` (when the player posted the listing), and `last_seen` is bumped on every re-scrape via UPSERT (so re-seeing the same listing never duplicates). Only items on the interest list (`gamedata/market_items.json`) are persisted - anything else is dropped at ingest. Listings expire 7 days after `created_at` OR after 3 hours with no re-scrape; `hide_expired=true` (default on reads) filters those out. `item_summary` computes an exact median via `percentile_cont`. `/insert` is **master-only**. - `GET /v1/market/listings[?name=&price_min=&price_max=&last_seen_after=&hide_expired=true&sort=-last_seen&limit=100&offset=0]` → `{ items: [Listing], count, total }`, Listing = `{ id, name, type, stack, price, price_each, last_seen, created_at, expires_at, expired }`. Default sort newest-`last_seen` first; pass `+price_each` to sort cheapest-first. `id` is the game's UUID v1 stringified. **Archive rate limit**: passing `hide_expired=false` (asking for expired/historical listings) pays a SECOND, tighter per-token bucket (default 10 req/min) on top of the standard cap - surfaced via `X-RateLimit-Archive-Limit` / `-Remaining` / `-Reset` response headers. The default `hide_expired=true` pays only the standard cap. (Market doesn't need a "90-day" threshold like leaderboards; the in-game 7-day listing lifetime already defines fresh-vs-historical, so the archive limit fires on the opt-in flag instead of an anchor age.) - `GET /v1/market/items` → `{ items: [], count }` - item names with at least one stored listing (sorted). - For the bot's scan allow-list see `GET /v1/misc/interest-items` (moved out of market - it's tokenless so dashboards / wikis can fetch it without a key). Admin-managed via `/admin/market/interest-items` (master panel). - `GET /v1/market/items/{name}/summary` → `{ name, count, total_price, total_stack, min_each, max_each, avg_each, median_each }` - aggregate stats across active (non-expired) listings of one item. 404 when no active listings exist. - `POST /v1/market/insert[?timestamp=]` (multipart `file`) - **master only**. Submit the raw cfg text. Returns `{ parsed, imported, ignored_not_in_list, last_seen }`. `timestamp` overrides the `last_seen` anchor (only for back-fills). Listings are upserted by UUID - same listing re-scraped bumps `last_seen`, never duplicates. **Ingest cooldown**: same per-token, per-endpoint cap as `/v1/leaderboards/insert` (default 1 per 300s; API-token only; session-JWT replay through the portal bypasses). ### OCR (`ocr:read`) - read character stats from a screenshot (self-hosted) Self-hosted OCR (RapidOCR / ONNX - **no external service, no LLM**) that reads the in-game character stat sheet from a screenshot. The UI is moddable (themes, fonts, columns, language), so this doesn't trust raw OCR text: every recognized label is fuzzy-matched against a CLOSED multilingual vocabulary (English + French) so a garbled or translated label still resolves to the right stat, and every value is sanity-checked against the stat's expected type (integer vs percent) + plausible range. **Token required** (not tokenless): the OCR model is CPU-heavy, so it's gated behind `ocr:read` rather than the per-IP anon budget. The image is processed in memory and never stored. - `POST /v1/ocr/character` (multipart `file`; PNG / JPEG / WebP / GIF / BMP, ≤12 MB) → `{ stats: { : { value, unit, raw, confidence, in_range, type_match, derived } }, matched, total_known, lines }`. `stats` is keyed by canonical stat key (`physical_damage`, `magic_damage`, `maximum_health`, `critical_hit`, `critical_damage`, `power_rank`, …). `value` is an integer for count stats and a float for percent stats; `unit` ∈ `count`/`percent`; `raw` is the number exactly as OCR read it (empty when derived); `confidence` (0-1) folds label-match quality with the validation result; `in_range`/`type_match` flag a value that parsed but looks implausible for that stat (don't trust a stat with either `false`). `derived` is `true` when a value was COMPUTED rather than read: **`coefficient`** often isn't shown on the sheet, so it's derived as `floor(max(physical_damage, magic_damage) * (1 + critical_damage/100))` (the game's own formula) from those reads; when coefficient IS shown and agrees it's a confirmed read (`derived:false`), and a shown value that contradicts the formula is surfaced but flagged with low `confidence`. `matched` = how many known stats are present, `total_known` = vocabulary size, `lines` = the raw OCR text lines (transparency). Returns **503** (`service_unavailable`) if the OCR engine isn't installed on the server, **400** on an unreadable/empty image. ## Meta - `GET /health` → `{ "status": "ok" }` - `GET /openapi.json` → full machine-readable OpenAPI spec --- ## Example ```bash export KIWI_TOKEN="kiwi_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" # created in the portal curl https://api.aallyn.net/v1/rotations/server-time \ -H "Authorization: Bearer $KIWI_TOKEN" ```