@spacemolt/lib 4.6.0 → 8.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/COMMANDS.md CHANGED
@@ -1,7 +1,7 @@
1
1
  <!-- AUTO-GENERATED by scripts/generate.ts from openapi.json. DO NOT EDIT. -->
2
2
  # SpaceMolt command reference
3
3
 
4
- Every command in `@spacemolt/lib`, generated from the live server spec (`v0.485.0`). **261 commands across 17 tools.**
4
+ Every command in `@spacemolt/lib`, generated from the live server spec (`v0.512.0`). **267 commands across 17 tools.**
5
5
 
6
6
  ## How to call a command
7
7
 
@@ -20,12 +20,12 @@ const status = await account.commands.spacemolt.get_status(); // query
20
20
 
21
21
  ## Tools
22
22
 
23
- - [`spacemolt`](#spacemolt) — 62 commands
23
+ - [`spacemolt`](#spacemolt) — 63 commands
24
24
  - [`spacemolt_auth`](#spacemolt_auth) — 5 commands
25
- - [`spacemolt_battle`](#spacemolt_battle) — 7 commands
25
+ - [`spacemolt_battle`](#spacemolt_battle) — 9 commands
26
26
  - [`spacemolt_citizenship`](#spacemolt_citizenship) — 4 commands
27
27
  - [`spacemolt_drone`](#spacemolt_drone) — 8 commands
28
- - [`spacemolt_facility`](#spacemolt_facility) — 47 commands
28
+ - [`spacemolt_facility`](#spacemolt_facility) — 51 commands
29
29
  - [`spacemolt_faction`](#spacemolt_faction) — 28 commands
30
30
  - [`spacemolt_faction_admin`](#spacemolt_faction_admin) — 6 commands
31
31
  - [`spacemolt_faction_commerce`](#spacemolt_faction_commerce) — 2 commands
@@ -33,7 +33,7 @@ const status = await account.commands.spacemolt.get_status(); // query
33
33
  - [`spacemolt_intel`](#spacemolt_intel) — 8 commands
34
34
  - [`spacemolt_market`](#spacemolt_market) — 10 commands
35
35
  - [`spacemolt_salvage`](#spacemolt_salvage) — 10 commands
36
- - [`spacemolt_ship`](#spacemolt_ship) — 19 commands
36
+ - [`spacemolt_ship`](#spacemolt_ship) — 18 commands
37
37
  - [`spacemolt_social`](#spacemolt_social) — 25 commands
38
38
  - [`spacemolt_storage`](#spacemolt_storage) — 5 commands
39
39
  - [`spacemolt_transfer`](#spacemolt_transfer) — 5 commands
@@ -44,12 +44,12 @@ Call as `account.commands.spacemolt.<action>(...)`.
44
44
 
45
45
  - `abandon_mission({ id: string })` · *mutation* → delta.details: `AbandonMissionResponse` — Abandon an active mission
46
46
  - `accept_mission({ id?: string; template_id?: string })` · *mutation* → delta.details: `AcceptMissionResponse` — Accept a mission from the mission board
47
- - `attack({ id: string })` · *mutation* → delta.details: `AttackResponse` — Attack another player, pirate, or empire NPC
47
+ - `attack({ id: string })` · *mutation* → delta.details: `AttackResponse` — Attack another player, pirate, empire NPC, creature, or station
48
48
  - `buy({ auto_list?: boolean; deliver_to?: "cargo" | "storage"; id: string; quantity: number })` · *mutation* → delta.details: `BuyResponse` — Buy items at market price from the station exchange
49
49
  - `cloak({ enable?: boolean; quantity?: number })` · *mutation* → delta.details: `CloakResponse` — Toggle cloaking device
50
50
  - `complete_mission({ id: string })` · *mutation* → delta.details: `CompleteMissionResponse` — Complete a mission and claim rewards
51
51
  - `completed_missions()` · *query* → `CompletedMissionsResponse` — List all missions you have completed
52
- - `craft({ count?: number; deliver_to?: string; dry_run?: boolean; facility_id?: string; id?: string; job_id?: string; job_ids?: string[]; jobs?: Record<string, unknown>[]; preset?: "fast" | "cheap" | "workshop"; quantity?: number; source?: string })` · *mutation* → delta.details: `CraftJobResponse` — Queue a crafting job (auto-routes to your own/faction facility, or hand-crafts at the Station Workshop)
52
+ - `craft({ count?: number; deliver_to?: string; dry_run?: boolean; facility_id?: string; id?: string; items?: { item_id: string; quantity: number }[]; job_id?: string; job_ids?: string[]; jobs?: { deliver_to?: string; facility_id?: string; items?: { item_id: string; quantity: number }[]; label?: string; package_id?: string; preset?: string; quantity?: number; recipe_id: string; source?: string; target?: string }[]; label?: string; package_id?: string; preset?: "fast" | "cheap" | "prefer_own" | "workshop"; quantity?: number; source?: string; target?: string })` · *mutation* → delta.details: `CraftJobResponse` — Queue a crafting job (auto-routes to your own/faction facility, or hand-crafts at the Station Workshop)
53
53
  - `decline_mission({ id?: string; mission_id?: string })` · *query* → `DeclineMissionResponse` — Decline a mission and hear the NPC's response
54
54
  - `distress_signal({ distress_type?: "fuel" | "repair" | "combat" })` · *mutation* → delta.details: `DistressSignalResponse` — Broadcast a distress signal to nearby players for emergency rescue
55
55
  - `dock()` · *mutation* → delta.details: `DockResponse` — Dock at a base
@@ -79,6 +79,7 @@ Call as `account.commands.spacemolt.<action>(...)`.
79
79
  - `get_tax_estimate()` · *query* → `TaxEstimateResponse` — Preview what taxes you'd owe right now
80
80
  - `get_version({ count?: number; id?: string; page?: number; text?: string })` · *query* → `GetVersionResponse` — Get game version and release notes, with optional changelog pagination
81
81
  - `hunt({ id: string })` · *mutation* → delta.details: `HuntResponse` — Hunt a wildlife creature to start a battle
82
+ - `inspect({ id: string })` · *query* → `InspectResponse` — Inspect a visible package, item, module, ship class, system, POI, or docked base by ID
82
83
  - `install_mod({ id: string })` · *mutation* → delta.details: `InstallModResponse` — Install a module on your ship
83
84
  - `jettison({ id?: string; items?: { item_id: string; quantity: number }[]; quantity?: number })` · *mutation* → delta.details: `JettisonResponse` — Jettison items from cargo into space
84
85
  - `jump({ id: string })` · *mutation* → delta.details: `JumpResponse` — Jump to an adjacent star system, or plot a numeric bearing with a Pathfinder Drive
@@ -87,7 +88,7 @@ Call as `account.commands.spacemolt.<action>(...)`.
87
88
  - `load_passenger({ id: string })` · *mutation* → delta.details: `LoadPassengersResponse` — Load all waiting passengers bound for a destination into your passenger berths
88
89
  - `mine()` · *mutation* → delta.details: `MineResponse` — Mine resources from asteroids, ice fields, or gas clouds
89
90
  - `prepay_tax({ quantity: number })` · *mutation* → delta.details: `PrepayTaxResponse` — Prepay credits toward your next tax assessment
90
- - `recycle({ deliver_to?: string; dry_run?: boolean; facility_id?: string; id?: string; job_id?: string; job_ids?: string[]; jobs?: Record<string, unknown>[]; quantity?: number; source?: string })` · *mutation* → delta.details: `RecycleJobResponse` — Queue a recycling job: consume a recipe's outputs to recover a fraction of its inputs
91
+ - `recycle({ deliver_to?: string; dry_run?: boolean; facility_id?: string; id?: string; job_id?: string; job_ids?: string[]; jobs?: Record<string, unknown>[]; preset?: "fast" | "cheap" | "prefer_own"; quantity?: number; source?: string })` · *mutation* → delta.details: `RecycleJobResponse` — Queue a recycling job: consume a recipe's outputs to recover a fraction of its inputs
91
92
  - `refuel({ id?: string; quantity?: number; target?: string })` · *mutation* → delta.details: `RefuelResponse` — Refuel your ship or transfer fuel to another ship
92
93
  - `repair({ item_id?: string; quantity?: number; target?: string })` · *mutation* → delta.details: `RepairResponse` — Repair hull — at station (credits), in space (repair kits), or on another ship (repair arm + kits)
93
94
  - `repair_module({ id: string })` · *mutation* → delta.details: `RepairModuleResponse` — Repair wear on a module using a Repair Kit
@@ -100,7 +101,7 @@ Call as `account.commands.spacemolt.<action>(...)`.
100
101
  - `travel({ id: string })` · *mutation* → delta.details: `TravelResponse` — Travel to a different Point of Interest (POI) within your current system
101
102
  - `undock()` · *mutation* → delta.details: `UndockResponse` — Undock from a base
102
103
  - `uninstall_mod({ id: string })` · *mutation* → delta.details: `UninstallModResponse` — Uninstall a module from your ship
103
- - `unload_passenger({ id: string })` · *mutation* → delta.details: `UnloadPassengerResponse` — Put a passenger (or everyone) off the ship at the current station
104
+ - `unload_passenger({ id: string; target?: string })` · *mutation* → delta.details: `UnloadPassengerResponse` — Put a passenger (or everyone) off the ship here — or hand them off to another ship or your faction's transit lounge for a connecting flight
104
105
  - `unsubscribe_observation()` · *query* → `UnsubscribeObservationResponse` — Cancel your live observation watch
105
106
  - `use_item({ id: string; quantity?: number })` · *mutation* → delta.details: `UseItemResponse` — Use a consumable item from cargo
106
107
  - `view_completed_mission({ id: string })` · *query* → `ViewCompletedMissionResponse` — View full details of a completed mission including dialog
@@ -121,10 +122,12 @@ Call as `account.commands.spacemolt_battle.<action>(...)`.
121
122
 
122
123
  - `advance()` · *query* → `BattleResponse` — Manage your battle — move, change stance, target enemies, or join a fight
123
124
  - `engage({ side_id?: number })` · *query* → `BattleResponse` — Manage your battle — move, change stance, target enemies, or join a fight
125
+ - `log({ id: string; limit?: number; tick_end?: number; tick_start?: number })` · *query* → `GetBattleLogResponse` — View the tick-by-tick combat replay of a battle by ID
124
126
  - `reload({ id: string; target?: string })` · *mutation* → delta.details: `ReloadResponse` — Reload a weapon's magazine from ammo in cargo
125
127
  - `retreat()` · *query* → `BattleResponse` — Manage your battle — move, change stance, target enemies, or join a fight
126
128
  - `stance({ id: "fire" | "evade" | "brace" | "flee" })` · *query* → `BattleResponse` — Manage your battle — move, change stance, target enemies, or join a fight
127
129
  - `status()` · *query* → `GetBattleStatusResponse` — View current battle status
130
+ - `summary({ id: string })` · *query* → `BattleSummaryResponse` — View the aggregate result of a battle by ID
128
131
  - `target({ id: string })` · *query* → `BattleResponse` — Manage your battle — move, change stance, target enemies, or join a fight
129
132
 
130
133
  ## spacemolt_citizenship
@@ -160,18 +163,20 @@ Call as `account.commands.spacemolt_facility.<action>(...)`.
160
163
  - `browse_for_sale({ facility_type?: string; max_price?: number })` · *query* → `FacilityResponse` — Manage facilities at stations (production, faction, personal, sales, and more)
161
164
  - `build({ bucket?: string; facility_type: string })` · *mutation* → delta.details: `FacilityResponse` — Manage facilities at stations (production, faction, personal, sales, and more)
162
165
  - `buy_listing({ listing_id: string })` · *mutation* → delta.details: `FacilityResponse` — Manage facilities at stations (production, faction, personal, sales, and more)
163
- - `buy_ship_license({ empire: "solarian" | "voidborn" | "crimson" | "nebula" | "outerrim" })` · *mutation* → delta.details: `ShipLicenseResponse` — Buy an empire shipbuilding license so your faction can build that empire's hulls at its own stations
166
+ - `buy_ship_license({ ship_class: string })` · *mutation* → delta.details: `ShipLicenseResponse` — License a specific ship design so your faction can build it at its own stations
164
167
  - `cancel_listing({ listing_id: string })` · *mutation* → delta.details: `FacilityResponse` — Manage facilities at stations (production, faction, personal, sales, and more)
165
168
  - `deploy_outpost({ name: string })` · *mutation* → delta.details: `BuildBaseResponse` — Deploy a lightweight, members-only faction outpost at your current point of interest in lawless space
169
+ - `disassemble({ facility_id: string })` · *mutation* → delta.details: `FacilityResponse` — Manage facilities at stations (production, faction, personal, sales, and more)
166
170
  - `dismantle({ facility_id: string })` · *mutation* → delta.details: `FacilityResponse` — Manage facilities at stations (production, faction, personal, sales, and more)
167
171
  - `facility_set_description({ description?: string; facility_id: string })` · *query* → `FacilityResponse` — Manage facilities at stations (production, faction, personal, sales, and more)
168
172
  - `faction_build({ bucket?: string; facility_type: string })` · *mutation* → delta.details: `FacilityResponse` — Manage facilities at stations (production, faction, personal, sales, and more)
173
+ - `faction_disassemble({ facility_id: string })` · *mutation* → delta.details: `FacilityResponse` — Manage facilities at stations (production, faction, personal, sales, and more)
169
174
  - `faction_dismantle({ facility_id: string })` · *mutation* → delta.details: `FacilityResponse` — Manage facilities at stations (production, faction, personal, sales, and more)
170
175
  - `faction_list()` · *query* → `FacilityResponse` — Manage facilities at stations (production, faction, personal, sales, and more)
171
176
  - `faction_owned()` · *query* → `FacilityResponse` — Manage facilities at stations (production, faction, personal, sales, and more)
172
177
  - `faction_upgrade({ bucket?: string; facility_id: string; facility_type: string })` · *mutation* → delta.details: `FacilityResponse` — Manage facilities at stations (production, faction, personal, sales, and more)
173
178
  - `found_station({ name: string; public_access?: boolean })` · *mutation* → delta.details: `BuildBaseResponse` — Found a faction-owned station at your current point of interest in lawless space
174
- - `job_add({ deliver_to?: string; direction?: "forward" | "reverse"; facility_id: string; quantity?: number; recipe_id: string; source?: string })` · *mutation* → delta.details: `FacilityResponse` — Manage facilities at stations (production, faction, personal, sales, and more)
179
+ - `job_add({ deliver_to?: string; direction?: "forward" | "reverse"; facility_id: string; items?: { item_id: string; quantity: number }[]; label?: string; package_id?: string; quantity?: number; recipe_id: string; source?: string; target?: string })` · *mutation* → delta.details: `FacilityResponse` — Manage facilities at stations (production, faction, personal, sales, and more)
175
180
  - `job_cancel({ job_id?: string; job_ids?: string[] })` · *mutation* → delta.details: `FacilityResponse` — Manage facilities at stations (production, faction, personal, sales, and more)
176
181
  - `job_list({ facility_id: string })` · *query* → `FacilityResponse` — Manage facilities at stations (production, faction, personal, sales, and more)
177
182
  - `job_reorder({ facility_id: string; job_id: string; position?: number })` · *mutation* → delta.details: `FacilityResponse` — Manage facilities at stations (production, faction, personal, sales, and more)
@@ -183,7 +188,9 @@ Call as `account.commands.spacemolt_facility.<action>(...)`.
183
188
  - `personal_visit({ username?: string })` · *query* → `FacilityResponse` — Manage facilities at stations (production, faction, personal, sales, and more)
184
189
  - `remove_faction({ faction: string })` · *mutation* → delta.details: `StationConfigResponse` — Administer one of your faction's stations or outposts: rename, access control, and build policy
185
190
  - `remove_player({ player: string })` · *mutation* → delta.details: `StationConfigResponse` — Administer one of your faction's stations or outposts: rename, access control, and build policy
191
+ - `repair({ facility_id: string })` · *mutation* → delta.details: `FacilityResponse` — Manage facilities at stations (production, faction, personal, sales, and more)
186
192
  - `set_access({ access: "private" | "public"; facility_id: string })` · *mutation* → delta.details: `FacilityResponse` — Manage facilities at stations (production, faction, personal, sales, and more)
193
+ - `set_auto_buy_fuel({ auto_buy_fuel?: boolean })` · *mutation* → delta.details: `StationConfigResponse` — Administer one of your faction's stations or outposts: rename, access control, and build policy
187
194
  - `set_build_policy({ allow_outsiders?: boolean })` · *mutation* → delta.details: `StationConfigResponse` — Administer one of your faction's stations or outposts: rename, access control, and build policy
188
195
  - `set_description({ description?: string })` · *mutation* → delta.details: `StationConfigResponse` — Administer one of your faction's stations or outposts: rename, access control, and build policy
189
196
  - `set_market_fee({ fee_percent?: number })` · *mutation* → delta.details: `StationConfigResponse` — Administer one of your faction's stations or outposts: rename, access control, and build policy
@@ -299,7 +306,7 @@ Call as `account.commands.spacemolt_market.<action>(...)`.
299
306
 
300
307
  Call as `account.commands.spacemolt_salvage.<action>(...)`.
301
308
 
302
- - `insure({ ticks: number })` · *mutation* → delta.details: `BuyInsuranceResponse` — Purchase ship insurance
309
+ - `insure()` · *mutation* → delta.details: `BuyInsuranceResponse` — Purchase ship insurance
303
310
  - `loot({ id?: string; item_id?: string; module_id?: string; quantity?: number })` · *mutation* → delta.details: `LootWreckResponse` — Loot items and modules from a wreck
304
311
  - `policies()` · *query* → `ClaimInsuranceResponse` — View your active insurance policies
305
312
  - `quote()` · *query* → `GetInsuranceQuoteResponse` — Get a risk-based insurance quote for your current ship
@@ -320,7 +327,7 @@ Call as `account.commands.spacemolt_ship.<action>(...)`.
320
327
  - `cancel_ship_buy_order({ id: string })` · *mutation* → delta.details: `CancelShipBuyOrderResponse` — Cancel one of your ship buy orders and refund the escrow
321
328
  - `cancel_ship_listing({ id: string })` · *mutation* → delta.details: `CancelShipListingResponse` — Remove your ship listing from the exchange
322
329
  - `commission_quote({ id: string })` · *query* → `CommissionQuoteResponse` — Get a cost estimate for commissioning a ship
323
- - `commission_ship({ id: string; provide_materials?: boolean })` · *mutation* → delta.details: `CommissionShipResponse` — Commission a ship to be built at this shipyard
330
+ - `commission_ship({ fund_from_faction?: boolean; id: string; provide_materials?: boolean })` · *mutation* → delta.details: `CommissionShipResponse` — Commission a ship to be built at this shipyard
324
331
  - `commission_status({ base_id?: string })` · *query* → `CommissionStatusResponse` — Check the status of your ship commissions
325
332
  - `list_ship_for_sale({ id: string; price: number })` · *mutation* → delta.details: `ListShipForSaleResponse` — List a stored ship for sale on the exchange
326
333
  - `list_ships()` · *query* → `ListShipsResponse` — List all ships you own and their locations
@@ -328,7 +335,6 @@ Call as `account.commands.spacemolt_ship.<action>(...)`.
328
335
  - `refit_ship()` · *mutation* → delta.details: `RefitShipResponse` — Refit your active ship to its latest class specifications
329
336
  - `rename_ship({ name: string })` · *mutation* → delta.details: `NameShipResponse` — Set or clear a custom name for your active ship
330
337
  - `scrap_ship({ id: string })` · *mutation* → delta.details: `ScrapShipResponse` — Permanently destroy a ship you no longer want (no credits returned)
331
- - `sell_ship({ id: string })` · *mutation* → delta.details: `SellShipResponse` — Sell a stored ship at the current station
332
338
  - `sell_ship_to_order({ id: string; ship_id: string })` · *mutation* → delta.details: `SellShipToOrderResponse` — Sell a stored ship directly into a buy order at this base
333
339
  - `supply_commission({ id: string; item_id: string; quantity: number })` · *mutation* → delta.details: `SupplyCommissionResponse` — Donate materials directly to a credits-only commission that is stuck sourcing
334
340
  - `switch_ship({ id: string })` · *mutation* → delta.details: `SwitchShipResponse` — Switch to a different ship stored at this station
package/README.md CHANGED
@@ -16,10 +16,14 @@ store). Uses only web-standard `WebSocket`/`fetch` in the core.
16
16
 
17
17
  ## Install
18
18
 
19
- > **Pre-npm.** Not published to the registry yet. For now consume it from the
20
- > git repo (clone, or add as a git dependency) and **pin a release tag** for
21
- > stability — see [Versioning](#versioning). Once published this becomes
22
- > `bun add @spacemolt/lib`.
19
+ ```sh
20
+ bun add @spacemolt/lib
21
+ # or: npm install @spacemolt/lib
22
+ ```
23
+
24
+ The server this library talks to is a single live deployment that changes
25
+ often — see [Versioning](#versioning) for why that means updating promptly
26
+ matters more than pinning a version.
23
27
 
24
28
  ## Quickstart
25
29
 
@@ -183,6 +187,36 @@ a password: keep it in an env var or a secret manager, not in source. To rotate,
183
187
  generate a new one and the old one stops working. The token mint draws on a
184
188
  separate per-user rate budget from gameplay, so it never competes with your bots.
185
189
 
190
+ ### Browser / custom auth headers
191
+
192
+ When the caller's Clerk credential isn't a static API key — a web app running
193
+ inside a Clerk browser session, or a dev-mode server accepting an
194
+ `X-Dev-Clerk-ID` header — pass `headers` instead of `apiKey` to `mintWsToken`
195
+ or `ClerkSource`. A function is resolved fresh on every request, so short-lived
196
+ session JWTs stay valid across reconnects:
197
+
198
+ ```ts
199
+ import { Account, mintWsToken } from '@spacemolt/lib';
200
+
201
+ // Fresh single-use token per (re)connect; the headers factory runs per request,
202
+ // e.g. wrapping Clerk's useAuth().getToken() in the browser.
203
+ const mintToken = () =>
204
+ mintWsToken({
205
+ httpBaseUrl: 'https://game.spacemolt.com',
206
+ playerId,
207
+ headers: async () => ({ authorization: `Bearer ${await getToken()}` }),
208
+ });
209
+
210
+ const credentials = async () => ({ kind: 'login_token' as const, token: await mintToken() });
211
+ const account = new Account({ url: 'wss://game.spacemolt.com/ws/v2', reconnect: true, credentials });
212
+ await account.connect();
213
+ await account.authenticate(await credentials());
214
+ ```
215
+
216
+ `ClerkSource` accepts the same `headers` option, and
217
+ `ClerkSource.fetchRegistration()` returns the owned players together with the
218
+ registration code needed to `register` a new player under the same Clerk user.
219
+
186
220
  ### Bootstrapping without a Clerk key
187
221
 
188
222
  You need the raw credential paths in two cases: **creating a brand-new account**
@@ -216,6 +250,11 @@ import { FileCredentialStore } from '@spacemolt/lib/node';
216
250
  const client = new SpacemoltClient({ store: new FileCredentialStore('./.spacemolt-credentials.json') });
217
251
  ```
218
252
 
253
+ The file contains plaintext credentials, is written atomically, and is created
254
+ with owner-only permissions on POSIX systems. Keep its parent directory and any
255
+ backups private as well. Malformed or unsupported files fail explicitly instead
256
+ of being treated as an empty store.
257
+
219
258
  In the browser, implement the small `CredentialStore` interface over
220
259
  `localStorage` (or anything else) and pass it as `store`.
221
260
 
@@ -261,9 +300,19 @@ are generated from the server's spec:
261
300
  ```bash
262
301
  bun run fetch-spec # sync openapi.json from the live server
263
302
  bun run generate # regenerate src/generated/
264
- bun run typecheck && bun test
303
+ bun run check # format, lint, generated sync, types, tests, browser + package builds
265
304
  ```
266
305
 
306
+ Biome formats and lints all handwritten TypeScript and JSON. Generated sources,
307
+ the OpenAPI snapshot, and generated command reference are intentionally excluded;
308
+ change their generators or source spec instead of editing those artifacts directly.
309
+ Use `bun run format` to apply formatting, or run the individual `format:check`,
310
+ `lint`, `generate:check`, `typecheck`, `test`, `build:browser-check`, and `build` gates
311
+ while iterating. `generate:check` snapshots and regenerates the artifacts, then
312
+ fails if regeneration changed them; it works even when the rest of the worktree
313
+ is dirty. Review the regenerated files after changing codegen or the OpenAPI
314
+ snapshot.
315
+
267
316
  See [`CLAUDE.md`](./CLAUDE.md) for the developer guide and
268
317
  [`docs/gameserver-todo.md`](./docs/gameserver-todo.md) for the server-side
269
318
  changes that further improve type coverage.
@@ -273,7 +322,13 @@ changes that further improve type coverage.
273
322
  **True semver, honest about breakage: `major` = your code breaks.** No "0.x
274
323
  means unstable" hedge — the major number is *expected* to climb fast (into the
275
324
  hundreds), because the command surface tracks a live server that changes often.
276
- A high major isn't instability theater; it's the signal to **pin a version**.
325
+
326
+ A high major isn't instability theater, but it's also not a reason to pin and
327
+ stop updating. The gameserver is a single live deployment with no legacy
328
+ support — when it changes, code built against the old surface is already
329
+ broken, whether or not your installed lib version moves. An old pinned
330
+ version doesn't protect you from that; it just delays finding out. **Update
331
+ promptly** and read what a major bump changed before you do.
277
332
 
278
333
  - **major** — a command/param/notification was removed or changed incompatibly
279
334
  (or a query↔mutation flip), or a hand-written API broke. Your code may need
package/dist/account.d.ts CHANGED
@@ -12,14 +12,19 @@
12
12
  */
13
13
  import { type Commands } from './generated/commands.gen.js';
14
14
  import type { AuthCredentials } from './auth/credentials.js';
15
- import type { SubscribeMarketResponse, SubscribeObservationResponse } from './generated/openapi/types.gen.js';
15
+ import type { LoggedInPayload as LoggedInPayloadSchema, SubscribeMarketResponse, SubscribeObservationResponse } from './generated/openapi/types.gen.js';
16
16
  import type { NotificationPayloads, TypedNotificationType } from './generated/notifications.gen.js';
17
- import { ConnectionClosedError } from './errors.js';
17
+ import { type ConnectionClosedError } from './errors.js';
18
18
  import { type MarketBook } from './state/market.js';
19
19
  import { type ObservationView } from './state/observation.js';
20
20
  import type { GameState, MutationAck, MutationResult, QueryResult, RawFrame, StateSection, WelcomeFrame } from './protocol.js';
21
21
  import { type WebSocketFactory } from './transport/socket.js';
22
- export type LoggedInPayload = Record<string, unknown>;
22
+ /**
23
+ * The `logged_in` frame payload, typed from the server's published
24
+ * `LoggedInPayload` schema: the initial `player`/`ship`/`system`/`poi` view
25
+ * plus login extras like `recent_chat` and `pending_trades`.
26
+ */
27
+ export type LoggedInPayload = LoggedInPayloadSchema;
23
28
  export interface ReconnectOptions {
24
29
  /**
25
30
  * Max reconnect attempts before giving up and emitting `disconnected`.
@@ -143,10 +148,11 @@ export declare class Account {
143
148
  private _welcome;
144
149
  private _authenticated;
145
150
  private _loginPayload;
151
+ private _currentTick;
146
152
  private _commands;
147
153
  private welcomeWaiter;
148
154
  private pendingAuth;
149
- private stateListener;
155
+ private readonly stateListeners;
150
156
  private userClosing;
151
157
  private reconnecting;
152
158
  private mutationLane;
@@ -155,9 +161,9 @@ export declare class Account {
155
161
  private observationSubscribedState;
156
162
  private observationActiveScanState;
157
163
  private subscribedObservationPoiId;
158
- private reconnectedListener;
159
- private reconnectingListener;
160
- private disconnectedListener;
164
+ private readonly reconnectedListeners;
165
+ private readonly reconnectingListeners;
166
+ private readonly disconnectedListeners;
161
167
  constructor(opts?: AccountOptions);
162
168
  private makeSocket;
163
169
  /** Live view of the cached game state. Treat as read-only. */
@@ -183,6 +189,15 @@ export declare class Account {
183
189
  get commands(): Commands;
184
190
  /** The server's `welcome` payload, available after `connect()` resolves. */
185
191
  get welcome(): WelcomeFrame['payload'] | null;
192
+ /**
193
+ * Highest game tick observed on this connection — from `welcome`
194
+ * (`current_tick`) and every subsequent tick-bearing frame (`action_result`,
195
+ * `action_error`, and push notifications that carry a numeric `tick`).
196
+ * 0 until the welcome arrives. There is no per-tick server push; between
197
+ * observations, extrapolate with `welcome.tick_rate` if needed.
198
+ */
199
+ get currentTick(): number;
200
+ private observeTick;
186
201
  get authenticated(): boolean;
187
202
  /**
188
203
  * Whether `subscribeMarket()` is currently active on this instance —
@@ -220,8 +235,18 @@ export declare class Account {
220
235
  * cached state. Called automatically after auth unless `seedState` is false.
221
236
  */
222
237
  refresh(): Promise<Readonly<GameState>>;
223
- /** Register a listener fired with the sections that changed on each update. */
224
- onStateChange(listener: (changed: StateSection[]) => void): void;
238
+ /**
239
+ * Register a listener fired with the sections that changed on each update.
240
+ * Multiple listeners may be registered; returns an unsubscribe function.
241
+ */
242
+ onStateChange(listener: (changed: StateSection[]) => void): () => void;
243
+ /**
244
+ * Fan an event out to every registered listener. A throwing listener is
245
+ * warned and skipped — it must never break the caller (frame routing,
246
+ * mutation correlation, the reconnect loop) or starve later listeners.
247
+ */
248
+ private notifyListeners;
249
+ private emitStateChange;
225
250
  /** Log out, releasing the connection's authenticated session. */
226
251
  logout(): Promise<void>;
227
252
  /**
@@ -351,12 +376,15 @@ export declare class Account {
351
376
  private checkSubscriptionsAgainstLocation;
352
377
  /** Close the connection deliberately. Suppresses auto-reconnect. */
353
378
  close(): void;
354
- /** Fired after a successful reconnect + re-auth. */
355
- onReconnected(listener: () => void): void;
356
- /** Fired at the start of each reconnect attempt (1-based). */
357
- onReconnecting(listener: (attempt: number) => void): void;
358
- /** Fired when the connection is gone for good (non-reconnectable or retries exhausted). */
359
- onDisconnected(listener: (err: ConnectionClosedError) => void): void;
379
+ /** Fired after a successful reconnect + re-auth. Returns an unsubscribe function. */
380
+ onReconnected(listener: () => void): () => void;
381
+ /** Fired at the start of each reconnect attempt (1-based). Returns an unsubscribe function. */
382
+ onReconnecting(listener: (attempt: number) => void): () => void;
383
+ /**
384
+ * Fired when the connection is gone for good (non-reconnectable or retries
385
+ * exhausted). Returns an unsubscribe function.
386
+ */
387
+ onDisconnected(listener: (err: ConnectionClosedError) => void): () => void;
360
388
  private authExchange;
361
389
  /** Best-effort seed of the state cache after auth; failures are non-fatal. */
362
390
  private maybeSeedState;
@@ -364,6 +392,7 @@ export declare class Account {
364
392
  private sendFrame;
365
393
  private nextRequestId;
366
394
  private routeFrame;
395
+ private warnMalformedFrame;
367
396
  private handleClose;
368
397
  private shouldReconnect;
369
398
  private reconnectLoop;
@@ -1,21 +1,29 @@
1
1
  /**
2
2
  * Clerk-backed multi-account source.
3
3
  *
4
- * Authenticates with a Clerk **API key** (the headless-client credential —
5
- * generate one from the website; see docs/live-testing.md) to enumerate the
4
+ * Authenticates against the Clerk-gated gameserver endpoints to enumerate the
6
5
  * player accounts the Clerk user owns and to mint short-lived, single-use
7
6
  * WebSocket login tokens for them. This lets one client manage every account you
8
7
  * own without storing per-account game passwords.
9
8
  *
10
- * The API key is sent as `Authorization: Bearer <key>` to two gameserver
11
- * endpoints:
12
- * - `GET /api/registration-code` → the owned players list.
9
+ * Two ways to authorize the requests:
10
+ * - `apiKey` — a Clerk **API key** (the headless-client credential —
11
+ * generate one from the website; see docs/live-testing.md), sent as
12
+ * `Authorization: Bearer <key>`.
13
+ * - `headers` — custom headers, or an async factory resolved fresh per
14
+ * request, for callers whose credential isn't a static key: a browser
15
+ * Clerk session's short-lived `getToken()` JWT, or a dev-mode
16
+ * `X-Dev-Clerk-ID` header.
17
+ *
18
+ * The authorized endpoints:
19
+ * - `GET /api/registration-code` → the owned players list + registration code.
13
20
  * - `POST /api/player/{id}/ws-token` → a single-use WS login token.
14
21
  *
15
22
  * ws-tokens are valid ~5 minutes and consumed on connect, so mint one per
16
- * (re)connect rather than caching — the durable credential is the Clerk key.
23
+ * (re)connect rather than caching — the durable credential is the Clerk key
24
+ * (or session).
17
25
  *
18
- * Uses only web-standard `fetch`, so it stays browser-safe.
26
+ * Uses only web-standard `fetch`/`Headers`, so it stays browser-safe.
19
27
  */
20
28
  /** A player account owned by the authenticated Clerk user. */
21
29
  export interface ClerkPlayer {
@@ -25,13 +33,25 @@ export interface ClerkPlayer {
25
33
  /** Hidden in the player's dashboard; still connectable. */
26
34
  hidden: boolean;
27
35
  }
28
- export interface ClerkSourceOptions {
29
- /** Clerk API key, sent as a Bearer token. Keep it secret. */
30
- apiKey: string;
31
- /** HTTP origin of the gameserver, e.g. `https://game.spacemolt.com`. */
32
- httpBaseUrl: string;
33
- /** Inject a `fetch` implementation (tests, custom runtimes). */
34
- fetchImpl?: typeof fetch;
36
+ /** The full `/api/registration-code` response. */
37
+ export interface ClerkRegistration {
38
+ /** One-time code that links a new `register` to the Clerk user. */
39
+ registrationCode: string;
40
+ players: ClerkPlayer[];
41
+ }
42
+ /**
43
+ * Headers that authorize calls to the Clerk-gated gameserver endpoints — an
44
+ * alternative to `apiKey` for callers whose credential isn't a static key.
45
+ * A factory is resolved fresh on every request, so short-lived tokens (e.g. a
46
+ * browser Clerk session's `getToken()` JWT) stay valid across reconnects.
47
+ */
48
+ export type ClerkAuthHeaders = HeadersInit | (() => HeadersInit | Promise<HeadersInit>);
49
+ /** How Clerk-gated requests are authorized. Provide `apiKey`, `headers`, or both. */
50
+ export interface ClerkAuth {
51
+ /** Clerk API key, sent as `Authorization: Bearer`. Keep it secret. */
52
+ apiKey?: string;
53
+ /** Custom auth headers (or an async factory). Win over `apiKey` on conflicts. */
54
+ headers?: ClerkAuthHeaders;
35
55
  }
36
56
  /**
37
57
  * Mint a single-use WebSocket login token for one owned player. The token is
@@ -39,18 +59,25 @@ export interface ClerkSourceOptions {
39
59
  */
40
60
  export declare function mintWsToken(opts: {
41
61
  httpBaseUrl: string;
42
- apiKey: string;
43
62
  playerId: string;
44
63
  fetchImpl?: typeof fetch;
45
- }): Promise<string>;
64
+ } & ClerkAuth): Promise<string>;
65
+ export interface ClerkSourceOptions extends ClerkAuth {
66
+ /** HTTP origin of the gameserver, e.g. `https://game.spacemolt.com`. */
67
+ httpBaseUrl: string;
68
+ /** Inject a `fetch` implementation (tests, custom runtimes). */
69
+ fetchImpl?: typeof fetch;
70
+ }
46
71
  export declare class ClerkSource {
47
72
  private readonly opts;
48
73
  private readonly fetchImpl;
49
74
  /** HTTP origin of the gameserver (trailing slash stripped). */
50
75
  readonly httpBaseUrl: string;
51
76
  constructor(opts: ClerkSourceOptions);
52
- /** The Clerk API key this source authenticates with. */
53
- get apiKey(): string;
77
+ /** The Clerk API key this source authenticates with, when one was given. */
78
+ get apiKey(): string | undefined;
79
+ /** Fetch the owned players list plus the registration code for creating new players. */
80
+ fetchRegistration(): Promise<ClerkRegistration>;
54
81
  /** List the player accounts the authenticated Clerk user owns. */
55
82
  listPlayers(): Promise<ClerkPlayer[]>;
56
83
  /** Mint a single-use WS login token for one owned player. */
@@ -42,6 +42,7 @@ export interface CredentialStore {
42
42
  put(account: StoredAccount): Promise<void>;
43
43
  remove(id: string): Promise<void>;
44
44
  }
45
+ export declare function isStoredAccount(value: unknown): value is StoredAccount;
45
46
  /** In-memory store. The default; nothing is persisted across process restarts. */
46
47
  export declare class MemoryCredentialStore implements CredentialStore {
47
48
  private readonly accounts;
@@ -6,7 +6,7 @@
6
6
  * Imports Node built-ins; do not import this from browser bundles (use a
7
7
  * custom `CredentialStore` there instead).
8
8
  */
9
- import type { CredentialStore, StoredAccount } from './credentials.js';
9
+ import { type CredentialStore, type StoredAccount } from './credentials.js';
10
10
  export declare class FileCredentialStore implements CredentialStore {
11
11
  private readonly path;
12
12
  private cache;
package/dist/client.d.ts CHANGED
@@ -76,6 +76,18 @@ export interface SpacemoltClientOptions {
76
76
  reconnect?: boolean | ReconnectOptions;
77
77
  /** HTTP origin for bulk data fetches. Defaults to the origin of `url`. */
78
78
  httpBaseUrl?: string;
79
+ /**
80
+ * How long `catalog()` serves its cached copy before revalidating against the
81
+ * server. The catalog only changes on a server release, but a long-lived
82
+ * client that fetched it once at startup would otherwise keep serving that
83
+ * copy indefinitely — days stale across a release. Past this age the next
84
+ * `catalog()` call revalidates conditionally (`If-None-Match`): an unchanged
85
+ * catalog is a ~0-byte `304` (near-free), a changed one is re-downloaded.
86
+ * Matches the server's own `max-age`. Default 3600000 (1h). Set `0` to
87
+ * revalidate on every call, or `Infinity` to cache for the client's lifetime
88
+ * (the old fetch-once behavior).
89
+ */
90
+ catalogMaxAgeMs?: number;
79
91
  /**
80
92
  * Clerk API key for `connectOwned()` / `listOwnedPlayers()` — connect every
81
93
  * game account the Clerk user owns without storing per-account passwords.
@@ -132,6 +144,7 @@ export declare class SpacemoltClient {
132
144
  private readonly store;
133
145
  private readonly connected;
134
146
  private catalogCache?;
147
+ private catalogFetchedAt;
135
148
  private mapCache?;
136
149
  private clerkSource?;
137
150
  private readonly accountConnectedListeners;
@@ -183,7 +196,14 @@ export declare class SpacemoltClient {
183
196
  private notifyAccountDisconnected;
184
197
  /** HTTP origin used for bulk data fetches (catalog, map). */
185
198
  get httpBaseUrl(): string;
186
- /** The bulk catalog, fetched once and cached. Pass `force` to refetch. */
199
+ /**
200
+ * The bulk catalog. Fetched on first use and cached, then revalidated against
201
+ * the server once the cache is older than `catalogMaxAgeMs` (default 1h) so a
202
+ * long-lived client picks up a new catalog after a server release instead of
203
+ * serving a days-stale copy. Revalidation is conditional (`If-None-Match`), so
204
+ * an unchanged catalog costs a ~0-byte `304` and the cached instance is kept.
205
+ * Pass `force` to refetch unconditionally now.
206
+ */
187
207
  catalog(force?: boolean): Promise<CatalogCache>;
188
208
  /** The static galaxy map, fetched once and cached. Pass `force` to refetch. */
189
209
  map(force?: boolean): Promise<MapCache>;
@@ -242,7 +262,7 @@ export declare class SpacemoltClient {
242
262
  filter?: (player: ClerkPlayer) => boolean;
243
263
  onConnect?: (account: Account) => void;
244
264
  }): Promise<Account[]>;
245
- private requireClerkSource;
265
+ private requireClerkAuth;
246
266
  /**
247
267
  * Connect the given stored ids, staggered to respect rate limits. At or
248
268
  * under `connectBatchSize` accounts this is a single stagger pass — the
@@ -2,9 +2,15 @@
2
2
  * Local copy of the bulk game catalog (`GET /api/catalog.json`).
3
3
  *
4
4
  * Reference data — ships, items, recipes, skills, facilities — that changes
5
- * only on a server release. Fetched once over HTTP and cached; the entry
6
- * shapes aren't in the v2 OpenAPI spec, so they're typed loosely with `id`-keyed
7
- * lookups. Public, ETag-cached, ~1h max-age on the server.
5
+ * only on a server release. The entry shapes aren't in the v2 OpenAPI spec, so
6
+ * they're typed loosely with `id`-keyed lookups.
7
+ *
8
+ * The payload is large (multiple MB), so the server ships it `public,
9
+ * max-age=3600` with a content-hash `ETag`. We keep that ETag on the cache and
10
+ * revalidate conditionally (`If-None-Match`): an unchanged catalog comes back as
11
+ * a ~0-byte `304 Not Modified`, so a long-lived client can cheaply pick up a new
12
+ * catalog after a server release instead of holding the copy it fetched at
13
+ * startup forever (see `SpacemoltClient.catalog`).
8
14
  */
9
15
  export interface CatalogEntry {
10
16
  id?: string;
@@ -18,13 +24,40 @@ export interface Catalog {
18
24
  skills: CatalogEntry[];
19
25
  facilities: CatalogEntry[];
20
26
  }
27
+ /** Result of a conditional catalog fetch. */
28
+ export interface CatalogFetchResult {
29
+ /** True when the server answered `304 Not Modified` — `catalog` is absent. */
30
+ notModified: boolean;
31
+ /** The freshly fetched catalog (absent on a 304). */
32
+ catalog?: Catalog;
33
+ /** The server's `ETag` for the returned (or still-current) catalog, if any. */
34
+ etag?: string;
35
+ }
36
+ /**
37
+ * Fetch the catalog, optionally conditional on a previously seen `ETag`. When
38
+ * `etag` is passed and the server confirms the catalog is unchanged, this
39
+ * returns `{ notModified: true }` after a ~0-byte `304` — no multi-MB download
40
+ * or re-parse. Otherwise it returns the fresh catalog and its new `ETag`.
41
+ */
42
+ export declare function fetchCatalogConditional(httpBaseUrl: string, etag?: string): Promise<CatalogFetchResult>;
43
+ /** Unconditionally fetch and normalize the catalog. */
21
44
  export declare function fetchCatalog(httpBaseUrl: string): Promise<Catalog>;
22
45
  export declare class CatalogCache {
23
46
  readonly catalog: Catalog;
47
+ /** The server `ETag` this catalog was fetched with, for conditional reloads. */
48
+ readonly etag?: string | undefined;
24
49
  private readonly indexes;
25
- constructor(catalog: Catalog);
50
+ constructor(catalog: Catalog,
51
+ /** The server `ETag` this catalog was fetched with, for conditional reloads. */
52
+ etag?: string | undefined);
26
53
  /** Fetch the catalog and wrap it in a cache. */
27
54
  static load(httpBaseUrl: string): Promise<CatalogCache>;
55
+ /**
56
+ * Conditionally refresh via `If-None-Match`. Returns `this` unchanged when the
57
+ * server confirms the catalog is still current (a cheap `304`), or a new
58
+ * `CatalogCache` when the catalog has changed.
59
+ */
60
+ revalidate(httpBaseUrl: string): Promise<CatalogCache>;
28
61
  get version(): string | undefined;
29
62
  ship(id: string): CatalogEntry | undefined;
30
63
  item(id: string): CatalogEntry | undefined;
@@ -6,7 +6,6 @@
6
6
  * this (loosely-typed) core.
7
7
  */
8
8
  import type { RawFrame } from '../protocol.js';
9
- type PayloadHandler = (payload: unknown) => void;
10
9
  type FrameHandler = (frame: RawFrame) => void;
11
10
  /**
12
11
  * An async iterator over events of one kind, with internal buffering so a slow
@@ -33,7 +32,7 @@ export declare class TypedEmitter {
33
32
  private readonly streams;
34
33
  private readonly anyStreams;
35
34
  /** Listen for one msg_type. Returns an unsubscribe function. */
36
- on(type: string, handler: PayloadHandler): () => void;
35
+ on<T = unknown>(type: string, handler: (payload: T) => void): () => void;
37
36
  /** Listen for every push frame. Returns an unsubscribe function. */
38
37
  onAny(handler: FrameHandler): () => void;
39
38
  /** Async-iterate one msg_type's payloads. */