# TLE Community Client TypeScript client for accessing TLE Community. ## Spec Discrepancies This client's types are generated from `@tlecommunity/api-spec` (OpenAPI spec for the `/v2` Rails API) via `openapi-typescript` (`npm run generate:types` → `lib/types/schema.ts`). Where the spec conflicts with this client's existing, tested behavior, the tested behavior wins. Differences found so far, for whoever owns the spec/backend. Everything below was re-verified against both the spec and the live server as of `@tlecommunity/api-spec` 1.4.0, with later releases checked entry-by-entry as they land; entries resolved by a release (and the accompanying backend work) have been removed. 1.4.0 normalised every id field to `integer`; the client already sent/typed ids as `number`, and `fixNumbers` coerces the ids the legacy server still stringifies on the wire, so that change was a no-op here. 1. **Numeric ids are typed as `string` in every request body schema** (`building_id`, `body_id`, `ship_id`, `spy_ids`, etc. - 1,208 of them), but the server accepts (and this client sends) `number`. Kept as `number` throughout. 2. **There is no generic `/v2/building/*` path** — every building type has its own module slug (`apple`, `shipyard`, `planetarycommand`, ...), so the generic `Building` class is only ever constructed with a concrete slug. `planetarycommand` gets its own subclass because it has 3 methods the generic class doesn't (`view_plans`, `view_incoming_supply_chains`, `subsidize_pod_cooldown`). 3. **`shipyard/get_buildable`'s `buildable.*.can` is typed `boolean`** but the server sends the IntBool convention (`0`/`1`) used pervasively elsewhere in this same API - including, since 1.1.0, `building.can` and `park.can_throw`. 4. **`reason` fields hold a `[code, message]` pair (sometimes a 3-element `[code, message, detail]`), the empty string, or the literal number `0` for "no reason".** 1.4.0 made `building.downgrade.reason` an array to match `building.upgrade.reason`, but neither the `array` nor the `string` declaration covers the `0` case, so the schema validator still widens every node named `reason` to accept array/number/string/null. 5. **Fields of any shape — arrays, objects, and plain strings — are sent as `null` instead of `[]`/`{}`/`""` when empty/absent** — a common Rails-ism the spec marks as nullable in exactly one place (`stats/empire_rank`'s `alliance_id`/`alliance_name`), and even there the server sends a number rather than the declared `["string", "null"]`. 6. **The `buildings_list` component wraps the id-keyed building map in a second `buildings` level.** It's `{buildings: {: {...}}}` but is `$ref`'d _as_ the `buildings` property in 628 places, so the spec asks for `result.buildings.buildings` where the server sends `result.buildings`. Affects `body/get_buildings` and every `build`/`upgrade`/`downgrade`/ `demolish`/`repair` response. New in 1.1.0. 7. **`/v2/map/get_star_map`'s star items omit the spec-required `bodies` field entirely** (rather than sending `[]`) when a star has no bodies. 8. **`/v2/empire/boost_*` responses are the full `{boosts}` map**, exactly like `view_boosts`, not the `{food_boost}`/`{ore_boost}`/... single-field shape the spec declares for each. 9. **`/v2/stats/alliance_rank` errors on its own default sort.** Called with no `sort_by` the server raises `Can't locate object method "influence desc,population desc" via package Lacuna::DB::Result::Log::Alliance`; every explicit `sort_by` value works. The spec documents `sort_by` as optional, so this client's `AllianceRankParams` requires it. 10. **`building.demolish`'s response is documented but unverified.** The spec says `{status, buildings}`; nothing here demolishes a building to check, so `DemolishResponse` mirrors the spec on trust. 11. **`/v2/empire/change_password` returns error 1002** ("Method ... is not recognized by the v2 API's argument map") - the backend migration off legacy Perl hasn't wired this route up yet. The legacy JSON-RPC endpoint serves it fine, so it's the `/v2` route specifically that's missing. Expected current backend state per CLAUDE.md, not a client bug; `empire.test.ts` asserts the 1002 outright, so it fails once the route lands. Every other endpoint this entry used to list - `archaeology/get_ores_available_for_processing`, `trade/view_market`, `transporter/view_market`, `empire/get_invite_friend_url`, `empire/get_species_templates` and every `inbox/*` method - is wired up as of 1.2.0 and now has a real test. 12. **`/v2/entertainment/get_lottery_voting_options` triggers a captcha challenge (error 1016).** The client surfaces the challenge to subscribers (see "Error handling" below) along with a retry callback, but it ships no captcha solver, so the test still skips rather than fails. 13. **`/v2/empire/update_species` is documented as requiring a session but must be called _without_ one.** It identifies the empire by `empire_id` (there is no session to be had until the empire is founded), and the v2 layer prepends the session id to the positional argument list whenever one is sent, which shifts every argument along by one. `updateSpecies` therefore uses `callWithoutSession`. 14. **`/v2/empire/redefine_species_limits` is documented as _not_ requiring a session but does** - the limits are computed from the current empire. Its `reason` is `null` rather than absent when a redefinition is allowed, which is why the schema validator now widens `reason` nodes to accept `null` alongside array/number/string. 15. **`/v2/empire/set_status_message` returns the status block itself** (`{empire, server}`, exactly like `get_status`), not the `{status}` wrapper the spec declares. 16. **`/v2/empire/authorize_sitters` returns `sitters`, not `auths`** - it's `view_authorized_sitters`' response plus `rejected_ids`, and those rejected ids are the bare ids/names as passed, not the `{id, reason}` objects the spec declares. (Last verified before the endpoint started demanding a captcha, which the test suite can't solve.) 17. **The empire profile methods send fields the spec doesn't document, and document fields they don't always send.** `view_profile` adds `skip_excavator_destroyed`, `skip_excavator_artifact`, `skip_excavator_replace_msg` and `dont_replace_excavator`; `view_public_profile` adds `alliance` and omits the spec-required `public` on each medal (the spec reuses `view_profile`'s medal schema, but every medal in a _public_ profile is public by definition). 18. **`map` star `influence` is spec-required `number` but arrives `null`** for stars outside any station's jurisdiction - `get_star_map`'s `stars[].influence` and `get_star` / `get_star_by_name` / `get_star_by_xy`'s `star.influence`. New in 1.4.0 (the field was added that release). The client types it `number | null`. 19. **`map/view_laws` law items still carry the misspelled key `descripition`.** 1.4.0 renamed it to `description` and made it required in the spec, but the live server hasn't been updated. (`parliament`'s own `view_laws` already sends `description`.) 20. **`wasterecycling` / `wasteexchanger` `view`'s `recycle` block is spec-too-strict.** `seconds_remaining`, `water`, `energy` and `ore` are only present while a recycling run is in progress, but the spec marks them required; and `seconds_per_resource` is fractional (e.g. `1.86`) while 1.4.0 types it `integer` (should be `number`). The client types the four conditional fields optional and `seconds_per_resource` as `number`. 21. **`body/get_buildable` diverges from its spec on both sides.** The `tag` param is spec-optional but the `/v2` layer rejects the call without it (pass `''` for "no filter"), and the response omits the spec-required top-level `max_items_in_build_queue`, sending `build_queue: {max, current}` instead. 22. **Building-list rows omit the spec-required per-row `id`.** 1.4.0 added a required `id` to each `buildings_list` / `buildings_resources_list` entry, but the server still only carries the id as the map key. `body.getBuildings`, `body.getBuildingsResources` and the `Building` `build`/`upgrade`/ `downgrade`/`demolish`/`repair` methods splice the key back onto each row (`util.spliceBuildingIds`), so callers see a self-describing row and the schema check passes. 23. **Status-block body lists carry an undocumented `type`.** Every entry of `get_status`'s `empire.bodies.colonies` / `mystations` / `ourstations` / `babies.*.planets` (the `bodies_list` component, new in 1.6.10) includes the body's image type (e.g. `p35`), which the spec doesn't declare. The client types it on `BodiesList`. Most of #1, #3, #4 and #5 are handled automatically by the schema validator behind the tests (`lib/__utils__/validate-schema.ts`) rather than by one-off client code, since they're systemic issues with how the spec describes the wire format. The rest are allowlisted per-call via `expectMatchesApiSchema`'s fourth argument, which fails the test if an allowlisted error _stops_ occurring — so a fix upstream surfaces here rather than going unnoticed. # Error handling Calls never throw. Every endpoint method resolves to a `ServerResponse`: `{ result }` on success, `{ error: { code, message, data } }` on failure. The client itself logs nothing about errors - what an error means is the caller's decision. ## Subscribing to responses `lacuna.onResponse(handler)` subscribes to every response the client receives, successful or not, and returns a function that unsubscribes: ```ts const unsubscribe = lacuna.onResponse(({ request, response, attempt, retry }) => { console.log(request.module, request.method, response.error ?? 'ok'); }); ``` The handler receives the request that produced the response, the resolved `ServerResponse`, the `attempt` number (0 on the original call), and a `retry` callback that re-issues the same request. For an **RPC limit error (1010, "slow down") or a captcha prompt (1016)** the originating call _waits_ for every handler to finish before returning, and a `ServerResponse` a handler returns replaces what the caller receives. So returning `await retry()` makes the caller see the retry's result instead of the error - which is what lets a long-running script survive hitting the server's per-minute request limit. Every other response is published without blocking, so the happy path pays no handler latency. Handlers are called in the order they were subscribed, and every one of them is called even after another has already resolved the error - subscribers see _all_ responses, including the attempts that were recovered from. Handlers after the one that recovered are given the resolved response, so a handler that keys off `response.error` (as the built-in one does) won't retry something that already succeeded. A retry re-publishes its own response as a fresh event with `attempt + 1`, so a handler that retries on some condition is simply called again while that condition holds. `retry()` stops issuing requests after 10 attempts as a backstop against a handler that retries unconditionally. ## Built-in RPC limit handler ```ts lacuna.enableRpcLimitHandler({ maxRetries: 5, baseDelayMs: 1000, maxDelayMs: 60000 }); ``` Logs each occurrence and retries with a progressive backoff (1s, 2s, 4s, 8s, 16s by default, clamped at `maxDelayMs`) until the call succeeds or `maxRetries` is reached, at which point the error is returned unchanged. All options are optional; the values above are the defaults. Returns a function that turns the handler back off. Logging goes through `lacuna.log`, whose default level (`warn`) prints to the console; `lacuna.log.setLogLevel('error')` silences it. Captcha prompts are deliberately **not** covered - the library can't solve an image. They're published like any other blocking error, so a consumer can solve one with `lacuna.captcha.fetch()` / `lacuna.captcha.solve()` and then call `retry()`. # License GNU General Public License v3.0