@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/dist/index.js CHANGED
@@ -1,4 +1,38 @@
1
+ // src/validation.ts
2
+ function isRecord(value) {
3
+ return typeof value === "object" && value !== null && !Array.isArray(value);
4
+ }
5
+ function requireRecord(value, source) {
6
+ if (!isRecord(value))
7
+ throw new TypeError(`${source} must be a JSON object`);
8
+ return value;
9
+ }
10
+
1
11
  // src/protocol.ts
12
+ function hasPayload(frame) {
13
+ return isRecord(frame.payload);
14
+ }
15
+ function isResultFrame(frame) {
16
+ return frame.type === "result" && hasPayload(frame) && "result" in frame.payload && (frame.payload.structuredContent === undefined || isRecord(frame.payload.structuredContent));
17
+ }
18
+ function isActionResultFrame(frame) {
19
+ return frame.type === "action_result" && hasPayload(frame) && typeof frame.payload.command === "string" && typeof frame.payload.tick === "number" && isRecord(frame.payload.result) && (frame.payload.auto_docked === undefined || typeof frame.payload.auto_docked === "boolean") && (frame.payload.auto_undocked === undefined || typeof frame.payload.auto_undocked === "boolean");
20
+ }
21
+ function isActionErrorFrame(frame) {
22
+ return frame.type === "action_error" && hasPayload(frame) && typeof frame.payload.command === "string" && typeof frame.payload.tick === "number" && typeof frame.payload.code === "string" && typeof frame.payload.message === "string" && (frame.payload.details === undefined || isRecord(frame.payload.details));
23
+ }
24
+ function isErrorFrame(frame) {
25
+ return frame.type === "error" && hasPayload(frame) && typeof frame.payload.code === "string" && typeof frame.payload.message === "string" && (frame.payload.details === undefined || isRecord(frame.payload.details)) && (frame.payload.pending_command === undefined || typeof frame.payload.pending_command === "string");
26
+ }
27
+ function isWelcomeFrame(frame) {
28
+ return frame.type === "welcome" && hasPayload(frame) && typeof frame.payload.version === "string" && typeof frame.payload.release_date === "string" && Array.isArray(frame.payload.release_notes) && frame.payload.release_notes.every((note) => typeof note === "string") && typeof frame.payload.tick_rate === "number" && typeof frame.payload.current_tick === "number" && typeof frame.payload.server_time === "number" && (frame.payload.motd === undefined || typeof frame.payload.motd === "string") && typeof frame.payload.game_info === "string" && typeof frame.payload.website === "string" && typeof frame.payload.help_text === "string" && typeof frame.payload.terms === "string";
29
+ }
30
+ function isLoggedInFrame(frame) {
31
+ return frame.type === "logged_in" && hasPayload(frame);
32
+ }
33
+ function isRegisteredFrame(frame) {
34
+ return frame.type === "registered" && hasPayload(frame) && typeof frame.payload.password === "string" && typeof frame.payload.player_id === "string";
35
+ }
2
36
  var STATE_SECTIONS = [
3
37
  "player",
4
38
  "ship",
@@ -10,7 +44,7 @@ var STATE_SECTIONS = [
10
44
  "skills"
11
45
  ];
12
46
  // src/generated/actions.gen.ts
13
- var GENERATED_SPEC_VERSION = "v0.485.0";
47
+ var GENERATED_SPEC_VERSION = "v0.512.0";
14
48
  var ACTIONS = {
15
49
  "spacemolt_auth/claim": {
16
50
  tool: "spacemolt_auth",
@@ -81,6 +115,19 @@ var ACTIONS = {
81
115
  { name: "side_id", type: "number", required: false, description: "Side to join (optional for action=engage — auto-assigned by faction if omitted)" }
82
116
  ]
83
117
  },
118
+ "spacemolt_battle/log": {
119
+ tool: "spacemolt_battle",
120
+ action: "log",
121
+ kind: "query",
122
+ summary: "View the tick-by-tick combat replay of a battle by ID",
123
+ responseType: "GetBattleLogResponse",
124
+ params: [
125
+ { name: "id", type: "string", required: true, description: "Battle ID to replay (active or completed, yours or not)", positional: 0 },
126
+ { name: "limit", type: "number", required: false, description: "Max ticks to return (default 50, max 200)" },
127
+ { name: "tick_end", type: "number", required: false, description: "Last tick to include (default unbounded)" },
128
+ { name: "tick_start", type: "number", required: false, description: "First tick to include (default 0)" }
129
+ ]
130
+ },
84
131
  "spacemolt_battle/reload": {
85
132
  tool: "spacemolt_battle",
86
133
  action: "reload",
@@ -118,6 +165,16 @@ var ACTIONS = {
118
165
  responseType: "GetBattleStatusResponse",
119
166
  params: []
120
167
  },
168
+ "spacemolt_battle/summary": {
169
+ tool: "spacemolt_battle",
170
+ action: "summary",
171
+ kind: "query",
172
+ summary: "View the aggregate result of a battle by ID",
173
+ responseType: "BattleSummaryResponse",
174
+ params: [
175
+ { name: "id", type: "string", required: true, description: "Battle ID to summarize (active or completed, yours or not)", positional: 0 }
176
+ ]
177
+ },
121
178
  "spacemolt_battle/target": {
122
179
  tool: "spacemolt_battle",
123
180
  action: "target",
@@ -322,10 +379,10 @@ var ACTIONS = {
322
379
  tool: "spacemolt_facility",
323
380
  action: "buy_ship_license",
324
381
  kind: "mutation",
325
- summary: "Buy an empire shipbuilding license so your faction can build that empire's hulls at its own stations",
382
+ summary: "License a specific ship design so your faction can build it at its own stations",
326
383
  detailsType: "ShipLicenseResponse",
327
384
  params: [
328
- { name: "empire", type: '"solarian" | "voidborn" | "crimson" | "nebula" | "outerrim"', required: true, description: "Empire to license shipbuilding from", enumValues: ["solarian", "voidborn", "crimson", "nebula", "outerrim"] }
385
+ { name: "ship_class", type: "string", required: true, description: "Ship class id to license (from ship_catalog), e.g. solarian_frigate" }
329
386
  ]
330
387
  },
331
388
  "spacemolt_facility/cancel_listing": {
@@ -348,6 +405,16 @@ var ACTIONS = {
348
405
  { name: "name", type: "string", required: true, description: "Name for the new faction outpost" }
349
406
  ]
350
407
  },
408
+ "spacemolt_facility/disassemble": {
409
+ tool: "spacemolt_facility",
410
+ action: "disassemble",
411
+ kind: "mutation",
412
+ summary: "Manage facilities at stations (production, faction, personal, sales, and more)",
413
+ detailsType: "FacilityResponse",
414
+ params: [
415
+ { name: "facility_id", type: "string", required: true, description: "Facility instance ID (required for 'upgrade', 'dismantle', 'disassemble', 'faction_dismantle', 'faction_disassemble', 'job_add', 'job_list', 'set_output_price', 'set_access', 'set_name', 'set_description' actions). Use action 'list' to see facility IDs.", positional: 1 }
416
+ ]
417
+ },
351
418
  "spacemolt_facility/dismantle": {
352
419
  tool: "spacemolt_facility",
353
420
  action: "dismantle",
@@ -355,7 +422,7 @@ var ACTIONS = {
355
422
  summary: "Manage facilities at stations (production, faction, personal, sales, and more)",
356
423
  detailsType: "FacilityResponse",
357
424
  params: [
358
- { name: "facility_id", type: "string", required: true, description: "Facility instance ID (required for 'upgrade', 'job_add', 'job_list', 'set_output_price', 'set_access', 'set_name', 'set_description' actions). Use action 'list' to see facility IDs.", positional: 1 }
425
+ { name: "facility_id", type: "string", required: true, description: "Facility instance ID (required for 'upgrade', 'dismantle', 'disassemble', 'faction_dismantle', 'faction_disassemble', 'job_add', 'job_list', 'set_output_price', 'set_access', 'set_name', 'set_description' actions). Use action 'list' to see facility IDs.", positional: 1 }
359
426
  ]
360
427
  },
361
428
  "spacemolt_facility/facility_set_description": {
@@ -366,7 +433,7 @@ var ACTIONS = {
366
433
  responseType: "FacilityResponse",
367
434
  params: [
368
435
  { name: "description", type: "string", required: false, description: "For 'personal_decorate': a text description of your personal quarters (what visitors see, hear, and feel). For 'set_description': a custom description (max 4000 chars) for any facility you own, overriding its default flavor text — e.g. re-flavoring your faction's bar. Send empty to clear it." },
369
- { name: "facility_id", type: "string", required: true, description: "Facility instance ID (required for 'upgrade', 'job_add', 'job_list', 'set_output_price', 'set_access', 'set_name', 'set_description' actions). Use action 'list' to see facility IDs.", positional: 1 }
436
+ { name: "facility_id", type: "string", required: true, description: "Facility instance ID (required for 'upgrade', 'dismantle', 'disassemble', 'faction_dismantle', 'faction_disassemble', 'job_add', 'job_list', 'set_output_price', 'set_access', 'set_name', 'set_description' actions). Use action 'list' to see facility IDs.", positional: 1 }
370
437
  ]
371
438
  },
372
439
  "spacemolt_facility/faction_build": {
@@ -380,6 +447,16 @@ var ACTIONS = {
380
447
  { name: "facility_type", type: "string", required: true, description: "Facility type ID. For 'types' action: get full details for this specific type. For 'build'/'upgrade': the type to build/upgrade to.", positional: 0 }
381
448
  ]
382
449
  },
450
+ "spacemolt_facility/faction_disassemble": {
451
+ tool: "spacemolt_facility",
452
+ action: "faction_disassemble",
453
+ kind: "mutation",
454
+ summary: "Manage facilities at stations (production, faction, personal, sales, and more)",
455
+ detailsType: "FacilityResponse",
456
+ params: [
457
+ { name: "facility_id", type: "string", required: true, description: "Facility instance ID (required for 'upgrade', 'dismantle', 'disassemble', 'faction_dismantle', 'faction_disassemble', 'job_add', 'job_list', 'set_output_price', 'set_access', 'set_name', 'set_description' actions). Use action 'list' to see facility IDs.", positional: 1 }
458
+ ]
459
+ },
383
460
  "spacemolt_facility/faction_dismantle": {
384
461
  tool: "spacemolt_facility",
385
462
  action: "faction_dismantle",
@@ -387,7 +464,7 @@ var ACTIONS = {
387
464
  summary: "Manage facilities at stations (production, faction, personal, sales, and more)",
388
465
  detailsType: "FacilityResponse",
389
466
  params: [
390
- { name: "facility_id", type: "string", required: true, description: "Facility instance ID (required for 'upgrade', 'job_add', 'job_list', 'set_output_price', 'set_access', 'set_name', 'set_description' actions). Use action 'list' to see facility IDs.", positional: 1 }
467
+ { name: "facility_id", type: "string", required: true, description: "Facility instance ID (required for 'upgrade', 'dismantle', 'disassemble', 'faction_dismantle', 'faction_disassemble', 'job_add', 'job_list', 'set_output_price', 'set_access', 'set_name', 'set_description' actions). Use action 'list' to see facility IDs.", positional: 1 }
391
468
  ]
392
469
  },
393
470
  "spacemolt_facility/faction_list": {
@@ -414,7 +491,7 @@ var ACTIONS = {
414
491
  detailsType: "FacilityResponse",
415
492
  params: [
416
493
  { name: "bucket", type: "string", required: false, description: "For 'faction_build'/'faction_upgrade': a Storage Extension bucket (name or id) to source build/upgrade MATERIALS from, instead of the faction main store. Ship cargo backfills either way." },
417
- { name: "facility_id", type: "string", required: true, description: "Facility instance ID (required for 'upgrade', 'job_add', 'job_list', 'set_output_price', 'set_access', 'set_name', 'set_description' actions). Use action 'list' to see facility IDs.", positional: 1 },
494
+ { name: "facility_id", type: "string", required: true, description: "Facility instance ID (required for 'upgrade', 'dismantle', 'disassemble', 'faction_dismantle', 'faction_disassemble', 'job_add', 'job_list', 'set_output_price', 'set_access', 'set_name', 'set_description' actions). Use action 'list' to see facility IDs.", positional: 1 },
418
495
  { name: "facility_type", type: "string", required: true, description: "Facility type ID. For 'types' action: get full details for this specific type. For 'build'/'upgrade': the type to build/upgrade to.", positional: 0 }
419
496
  ]
420
497
  },
@@ -438,10 +515,14 @@ var ACTIONS = {
438
515
  params: [
439
516
  { name: "deliver_to", type: "string", required: false, description: "Output destination for 'job_add': 'storage' (default), 'faction' (faction main store), or 'faction:<bucket name or id>' for a Storage Extension bucket." },
440
517
  { name: "direction", type: '"forward" | "reverse"', required: false, description: "Job direction: 'forward' runs the recipe normally (craft); 'reverse' recycles existing items back to inputs.", enumValues: ["forward", "reverse"] },
441
- { name: "facility_id", type: "string", required: true, description: "Facility instance ID (required for 'upgrade', 'job_add', 'job_list', 'set_output_price', 'set_access', 'set_name', 'set_description' actions). Use action 'list' to see facility IDs.", positional: 1 },
518
+ { name: "facility_id", type: "string", required: true, description: "Facility instance ID (required for 'upgrade', 'dismantle', 'disassemble', 'faction_dismantle', 'faction_disassemble', 'job_add', 'job_list', 'set_output_price', 'set_access', 'set_name', 'set_description' actions). Use action 'list' to see facility IDs.", positional: 1 },
519
+ { name: "items", type: "{ item_id: string; quantity: number }[]", required: false, description: "For job_add pack_package: selected manifest items." },
520
+ { name: "label", type: "string", required: false, description: "For job_add pack_package: player label." },
521
+ { name: "package_id", type: "string", required: false, description: "For job_add unpack_package: package ID." },
442
522
  { name: "quantity", type: "number", required: false, description: "For 'job_add': number of runs to queue." },
443
523
  { name: "recipe_id", type: "string", required: true, description: "Recipe ID to run (for 'job_add' action)." },
444
- { name: "source", type: "string", required: false, description: "Input source for 'job_add': where inputs/credits are pulled from. Same values as deliver_to; defaults to deliver_to." }
524
+ { name: "source", type: "string", required: false, description: "Input source for 'job_add': where inputs/credits are pulled from. Same values as deliver_to; defaults to deliver_to." },
525
+ { name: "target", type: "string", required: false, description: "Package job_add output destination; defaults to source." }
445
526
  ]
446
527
  },
447
528
  "spacemolt_facility/job_cancel": {
@@ -462,7 +543,7 @@ var ACTIONS = {
462
543
  summary: "Manage facilities at stations (production, faction, personal, sales, and more)",
463
544
  responseType: "FacilityResponse",
464
545
  params: [
465
- { name: "facility_id", type: "string", required: true, description: "Facility instance ID (required for 'upgrade', 'job_add', 'job_list', 'set_output_price', 'set_access', 'set_name', 'set_description' actions). Use action 'list' to see facility IDs.", positional: 1 }
546
+ { name: "facility_id", type: "string", required: true, description: "Facility instance ID (required for 'upgrade', 'dismantle', 'disassemble', 'faction_dismantle', 'faction_disassemble', 'job_add', 'job_list', 'set_output_price', 'set_access', 'set_name', 'set_description' actions). Use action 'list' to see facility IDs.", positional: 1 }
466
547
  ]
467
548
  },
468
549
  "spacemolt_facility/job_reorder": {
@@ -472,7 +553,7 @@ var ACTIONS = {
472
553
  summary: "Manage facilities at stations (production, faction, personal, sales, and more)",
473
554
  detailsType: "FacilityResponse",
474
555
  params: [
475
- { name: "facility_id", type: "string", required: true, description: "Facility instance ID (required for 'upgrade', 'job_add', 'job_list', 'set_output_price', 'set_access', 'set_name', 'set_description' actions). Use action 'list' to see facility IDs.", positional: 1 },
556
+ { name: "facility_id", type: "string", required: true, description: "Facility instance ID (required for 'upgrade', 'dismantle', 'disassemble', 'faction_dismantle', 'faction_disassemble', 'job_add', 'job_list', 'set_output_price', 'set_access', 'set_name', 'set_description' actions). Use action 'list' to see facility IDs.", positional: 1 },
476
557
  { name: "job_id", type: "string", required: true, description: "Job ID (for 'job_cancel', 'job_reorder'). Use action 'job_list' to see job IDs." },
477
558
  { name: "position", type: "number", required: false, description: "New queue position for 'job_reorder' (1-based)." }
478
559
  ]
@@ -492,9 +573,9 @@ var ACTIONS = {
492
573
  summary: "Manage facilities at stations (production, faction, personal, sales, and more)",
493
574
  detailsType: "FacilityResponse",
494
575
  params: [
495
- { name: "facility_id", type: "string", required: true, description: "Facility instance ID (required for 'upgrade', 'job_add', 'job_list', 'set_output_price', 'set_access', 'set_name', 'set_description' actions). Use action 'list' to see facility IDs.", positional: 1 },
576
+ { name: "facility_id", type: "string", required: true, description: "Facility instance ID (required for 'upgrade', 'dismantle', 'disassemble', 'faction_dismantle', 'faction_disassemble', 'job_add', 'job_list', 'set_output_price', 'set_access', 'set_name', 'set_description' actions). Use action 'list' to see facility IDs.", positional: 1 },
496
577
  { name: "faction", type: "boolean", required: false, description: "For 'list_for_sale': set true to list a faction-owned facility (requires manage_facilities permission)." },
497
- { name: "price", type: "number", required: true, description: "For 'list_for_sale': asking price in whole credits. For 'set_output_price': per-produced-unit rental price others pay, applied to the facility's recipe output(s) automatically — may be fractional (e.g. 0.25), the per-run fee is price × output quantity rounded to a whole credit. Used literally: 0 rents the facility out for free; negative is rejected." }
578
+ { name: "price", type: "number", required: true, description: "For 'list_for_sale': asking price in whole credits. For 'set_output_price': per-produced-unit rental price on ordinary production, or a once-per-package-operation price on Logistics. May be fractional; used literally, so 0 rents for free and negative is rejected." }
498
579
  ]
499
580
  },
500
581
  "spacemolt_facility/owned": {
@@ -556,6 +637,16 @@ var ACTIONS = {
556
637
  { name: "player", type: "string", required: true, description: "Target player id or username (allow_player/remove_player/ban/unban)" }
557
638
  ]
558
639
  },
640
+ "spacemolt_facility/repair": {
641
+ tool: "spacemolt_facility",
642
+ action: "repair",
643
+ kind: "mutation",
644
+ summary: "Manage facilities at stations (production, faction, personal, sales, and more)",
645
+ detailsType: "FacilityResponse",
646
+ params: [
647
+ { name: "facility_id", type: "string", required: true, description: "Facility instance ID (required for 'upgrade', 'dismantle', 'disassemble', 'faction_dismantle', 'faction_disassemble', 'job_add', 'job_list', 'set_output_price', 'set_access', 'set_name', 'set_description' actions). Use action 'list' to see facility IDs.", positional: 1 }
648
+ ]
649
+ },
559
650
  "spacemolt_facility/set_access": {
560
651
  tool: "spacemolt_facility",
561
652
  action: "set_access",
@@ -564,7 +655,17 @@ var ACTIONS = {
564
655
  detailsType: "FacilityResponse",
565
656
  params: [
566
657
  { name: "access", type: '"private" | "public"', required: true, description: "For 'personal_decorate': who can visit your quarters. For 'set_access': 'public' opens your facility to renters, 'private' closes it.", enumValues: ["private", "public"] },
567
- { name: "facility_id", type: "string", required: true, description: "Facility instance ID (required for 'upgrade', 'job_add', 'job_list', 'set_output_price', 'set_access', 'set_name', 'set_description' actions). Use action 'list' to see facility IDs.", positional: 1 }
658
+ { name: "facility_id", type: "string", required: true, description: "Facility instance ID (required for 'upgrade', 'dismantle', 'disassemble', 'faction_dismantle', 'faction_disassemble', 'job_add', 'job_list', 'set_output_price', 'set_access', 'set_name', 'set_description' actions). Use action 'list' to see facility IDs.", positional: 1 }
659
+ ]
660
+ },
661
+ "spacemolt_facility/set_auto_buy_fuel": {
662
+ tool: "spacemolt_facility",
663
+ action: "set_auto_buy_fuel",
664
+ kind: "mutation",
665
+ summary: "Administer one of your faction's stations or outposts: rename, access control, and build policy",
666
+ detailsType: "StationConfigResponse",
667
+ params: [
668
+ { name: "auto_buy_fuel", type: "boolean", required: false, description: "Whether the station automatically buys fuel from docked pilots at live scarcity-based prices, funded by the station economy (set_auto_buy_fuel; off by default)" }
568
669
  ]
569
670
  },
570
671
  "spacemolt_facility/set_build_policy": {
@@ -605,7 +706,7 @@ var ACTIONS = {
605
706
  detailsType: "FacilityResponse",
606
707
  params: [
607
708
  { name: "custom_name", type: "string", required: false, description: "For 'set_name': a custom name for the facility (3-32 chars) so multiple facilities of the same type stand out. Send empty to clear it." },
608
- { name: "facility_id", type: "string", required: true, description: "Facility instance ID (required for 'upgrade', 'job_add', 'job_list', 'set_output_price', 'set_access', 'set_name', 'set_description' actions). Use action 'list' to see facility IDs.", positional: 1 }
709
+ { name: "facility_id", type: "string", required: true, description: "Facility instance ID (required for 'upgrade', 'dismantle', 'disassemble', 'faction_dismantle', 'faction_disassemble', 'job_add', 'job_list', 'set_output_price', 'set_access', 'set_name', 'set_description' actions). Use action 'list' to see facility IDs.", positional: 1 }
609
710
  ]
610
711
  },
611
712
  "spacemolt_facility/set_output_price": {
@@ -615,8 +716,8 @@ var ACTIONS = {
615
716
  summary: "Manage facilities at stations (production, faction, personal, sales, and more)",
616
717
  detailsType: "FacilityResponse",
617
718
  params: [
618
- { name: "facility_id", type: "string", required: true, description: "Facility instance ID (required for 'upgrade', 'job_add', 'job_list', 'set_output_price', 'set_access', 'set_name', 'set_description' actions). Use action 'list' to see facility IDs.", positional: 1 },
619
- { name: "price", type: "number", required: false, description: "For 'list_for_sale': asking price in whole credits. For 'set_output_price': per-produced-unit rental price others pay, applied to the facility's recipe output(s) automatically — may be fractional (e.g. 0.25), the per-run fee is price × output quantity rounded to a whole credit. Used literally: 0 rents the facility out for free; negative is rejected." }
719
+ { name: "facility_id", type: "string", required: true, description: "Facility instance ID (required for 'upgrade', 'dismantle', 'disassemble', 'faction_dismantle', 'faction_disassemble', 'job_add', 'job_list', 'set_output_price', 'set_access', 'set_name', 'set_description' actions). Use action 'list' to see facility IDs.", positional: 1 },
720
+ { name: "price", type: "number", required: false, description: "For 'list_for_sale': asking price in whole credits. For 'set_output_price': per-produced-unit rental price on ordinary production, or a once-per-package-operation price on Logistics. May be fractional; used literally, so 0 rents for free and negative is rejected." }
620
721
  ]
621
722
  },
622
723
  "spacemolt_facility/set_public": {
@@ -686,7 +787,7 @@ var ACTIONS = {
686
787
  detailsType: "FacilityResponse",
687
788
  params: [
688
789
  { name: "direction", type: '"to_faction" | "to_player"', required: true, description: "Transfer direction: 'to_faction' moves the facility to faction ownership; 'to_player' transfers it to a specific player.", enumValues: ["to_faction", "to_player"] },
689
- { name: "facility_id", type: "string", required: true, description: "Facility instance ID (required for 'upgrade', 'job_add', 'job_list', 'set_output_price', 'set_access', 'set_name', 'set_description' actions). Use action 'list' to see facility IDs.", positional: 1 },
790
+ { name: "facility_id", type: "string", required: true, description: "Facility instance ID (required for 'upgrade', 'dismantle', 'disassemble', 'faction_dismantle', 'faction_disassemble', 'job_add', 'job_list', 'set_output_price', 'set_access', 'set_name', 'set_description' actions). Use action 'list' to see facility IDs.", positional: 1 },
690
791
  { name: "player_id", type: "string", required: false, description: "Target player ID for 'transfer' action with direction 'to_player'." }
691
792
  ]
692
793
  },
@@ -723,7 +824,7 @@ var ACTIONS = {
723
824
  detailsType: "FacilityResponse",
724
825
  params: [
725
826
  { name: "bucket", type: "string", required: false, description: "For 'faction_build'/'faction_upgrade': a Storage Extension bucket (name or id) to source build/upgrade MATERIALS from, instead of the faction main store. Ship cargo backfills either way." },
726
- { name: "facility_id", type: "string", required: true, description: "Facility instance ID (required for 'upgrade', 'job_add', 'job_list', 'set_output_price', 'set_access', 'set_name', 'set_description' actions). Use action 'list' to see facility IDs.", positional: 1 },
827
+ { name: "facility_id", type: "string", required: true, description: "Facility instance ID (required for 'upgrade', 'dismantle', 'disassemble', 'faction_dismantle', 'faction_disassemble', 'job_add', 'job_list', 'set_output_price', 'set_access', 'set_name', 'set_description' actions). Use action 'list' to see facility IDs.", positional: 1 },
727
828
  { name: "facility_type", type: "string", required: true, description: "Facility type ID. For 'types' action: get full details for this specific type. For 'build'/'upgrade': the type to build/upgrade to.", positional: 0 }
728
829
  ]
729
830
  },
@@ -1412,9 +1513,7 @@ var ACTIONS = {
1412
1513
  kind: "mutation",
1413
1514
  summary: "Purchase ship insurance",
1414
1515
  detailsType: "BuyInsuranceResponse",
1415
- params: [
1416
- { name: "ticks", type: "number", required: true, description: "Number of ticks to insure for" }
1417
- ]
1516
+ params: []
1418
1517
  },
1419
1518
  "spacemolt_salvage/loot": {
1420
1519
  tool: "spacemolt_salvage",
@@ -1566,8 +1665,9 @@ var ACTIONS = {
1566
1665
  summary: "Commission a ship to be built at this shipyard",
1567
1666
  detailsType: "CommissionShipResponse",
1568
1667
  params: [
1668
+ { name: "fund_from_faction", type: "boolean", required: false, description: "At your own faction's station: build from faction storage and treasury (requires ManageTreasury). Required there; credits-only and provide_materials are rejected." },
1569
1669
  { name: "id", type: "string", required: true, description: "Ship class ID to commission (use ship_catalog to see options)", positional: 0 },
1570
- { name: "provide_materials", type: "boolean", required: false, description: "If true, supply build materials from cargo/storage (cheaper). If false, pay credits for everything (default)." }
1670
+ { name: "provide_materials", type: "boolean", required: false, description: "At an empire/NPC shipyard: if true, supply build materials from cargo/storage (cheaper); if false, pay credits for everything (default)." }
1571
1671
  ]
1572
1672
  },
1573
1673
  "spacemolt_ship/commission_status": {
@@ -1638,16 +1738,6 @@ var ACTIONS = {
1638
1738
  { name: "id", type: "string", required: true, description: "ID of the ship to permanently destroy (no credits returned). Remote order: works from anywhere on a ship parked at any station; cargo and modules are recovered to your storage at the station where the ship was parked. Use list_ships to see your fleet.", positional: 0 }
1639
1739
  ]
1640
1740
  },
1641
- "spacemolt_ship/sell_ship": {
1642
- tool: "spacemolt_ship",
1643
- action: "sell_ship",
1644
- kind: "mutation",
1645
- summary: "Sell a stored ship at the current station",
1646
- detailsType: "SellShipResponse",
1647
- params: [
1648
- { name: "id", type: "string", required: true, description: "ID of the stored ship to sell (use list_ships to see your fleet)", positional: 0 }
1649
- ]
1650
- },
1651
1741
  "spacemolt_ship/sell_ship_to_order": {
1652
1742
  tool: "spacemolt_ship",
1653
1743
  action: "sell_ship_to_order",
@@ -2117,10 +2207,10 @@ var ACTIONS = {
2117
2207
  tool: "spacemolt",
2118
2208
  action: "attack",
2119
2209
  kind: "mutation",
2120
- summary: "Attack another player, pirate, or empire NPC",
2210
+ summary: "Attack another player, pirate, empire NPC, creature, or station",
2121
2211
  detailsType: "AttackResponse",
2122
2212
  params: [
2123
- { name: "id", type: "string", required: true, description: "Player ID to attack", positional: 0 }
2213
+ { name: "id", type: "string", required: true, description: "ID of the target: a player, pirate, empire NPC, wildlife creature, or station. Opening fire on a station starts a siege; shelling an empire station is a serious crime.", positional: 0 }
2124
2214
  ]
2125
2215
  },
2126
2216
  "spacemolt/buy": {
@@ -2177,12 +2267,16 @@ var ACTIONS = {
2177
2267
  { name: "dry_run", type: "boolean", required: false, description: "Return a cost+time quote (materials, labor, rental fee, auto-routed venue, ETA) without queuing or spending anything. Not supported with bulk jobs." },
2178
2268
  { name: "facility_id", type: "string", required: false, description: "Route to a specific facility ID (overrides auto-selection)." },
2179
2269
  { name: "id", type: "string", required: false, description: "Recipe ID to craft (use catalog with type=recipes to see available recipes). Inputs are escrowed from station storage at enqueue.", positional: 0 },
2270
+ { name: "items", type: "{ item_id: string; quantity: number }[]", required: false, description: "For pack_package: selected items to pack; total unpacked size may not exceed 100." },
2180
2271
  { name: "job_id", type: "string", required: false, description: "Cancel this queued job (refunding its unconsumed inputs, labor, and rental fee) instead of crafting. Use action='queue' to list your job IDs. Setting job_id implies cancel." },
2181
2272
  { name: "job_ids", type: "string[]", required: false, description: "Bulk cancel: cancel many queued jobs in one action. Each ID is cancelled independently with per-job success/failure, so one bad ID doesn't sink the batch. Refunds the unconsumed escrow of every cancelled job." },
2182
- { name: "jobs", type: "Record<string, unknown>[]", required: false, description: "Bulk mode: queue many crafts in one action. Each entry: {recipe_id, quantity, facility_id?, preset?, deliver_to?, source?}. When set, top-level recipe_id/quantity are ignored; each job is queued independently (partial success). Max 50." },
2183
- { name: "preset", type: '"fast" | "cheap" | "workshop"', required: false, description: "Auto-routing preset: 'fast' (fewest ticks, default) or 'cheap' (lowest fee). Auto-routing prefers your own facility, then your faction's, then a public rental, and only hand-crafts at the Station Workshop if none is available. Use 'workshop' to force hand-crafting even when you have a facility.", enumValues: ["fast", "cheap", "workshop"] },
2273
+ { name: "jobs", type: "{ 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 }[]", required: false, description: "Bulk mode: queue many crafts in one action. Package entries may include items, package_id, label, and target. Each job commits independently with partial success. Max 50." },
2274
+ { name: "label", type: "string", required: false, description: "For pack_package: player-authored package label." },
2275
+ { name: "package_id", type: "string", required: false, description: "For unpack_package: package instance ID to unpack." },
2276
+ { name: "preset", type: '"fast" | "cheap" | "prefer_own" | "workshop"', required: false, description: "Auto-routing preset: 'fast' (fewest ticks, default) or 'cheap' (lowest fee) — both pick the best facility globally, so a busy own facility may route to an idle public rental. Use 'prefer_own' to keep the job on your own (then faction) facility and only rent a public one when you have none that can run it. Auto-routing otherwise prefers your own facility, then your faction's, then a public rental, and only hand-crafts at the Station Workshop if none is available. Use 'workshop' to force hand-crafting even when you have a facility.", enumValues: ["fast", "cheap", "prefer_own", "workshop"] },
2184
2277
  { name: "quantity", type: "number", required: false, description: "Number of output items to make (default 1). Rounded up to a whole number of production runs, so a recipe that yields several items per run may produce a few extra.", positional: 1 },
2185
- { name: "source", type: "string", required: false, description: "Where inputs and labor/rental credits are pulled FROM. Same values as deliver_to: 'storage', 'faction', or 'faction:<bucket>'. Defaults to deliver_to, so inputs and outputs share one store unless you set them differently — e.g. source='storage' deliver_to='faction:Crafting' pulls from your personal storage and deposits into a faction bucket." }
2278
+ { name: "source", type: "string", required: false, description: "Where inputs and labor/rental credits are pulled FROM. Same values as deliver_to: 'storage', 'faction', or 'faction:<bucket>'. Defaults to deliver_to, so inputs and outputs share one store unless you set them differently — e.g. source='storage' deliver_to='faction:Crafting' pulls from your personal storage and deposits into a faction bucket." },
2279
+ { name: "target", type: "string", required: false, description: "For package recipes: output destination (storage, cargo, faction, or faction:<bucket>); defaults to source. deliver_to is accepted as an alias." }
2186
2280
  ]
2187
2281
  },
2188
2282
  "spacemolt/decline_mission": {
@@ -2441,6 +2535,16 @@ var ACTIONS = {
2441
2535
  { name: "id", type: "string", required: true, description: "Creature ID to hunt (from the 'creatures' list in get_nearby)", positional: 0 }
2442
2536
  ]
2443
2537
  },
2538
+ "spacemolt/inspect": {
2539
+ tool: "spacemolt",
2540
+ action: "inspect",
2541
+ kind: "query",
2542
+ summary: "Inspect a visible package, item, module, ship class, system, POI, or docked base by ID",
2543
+ responseType: "InspectResponse",
2544
+ params: [
2545
+ { name: "id", type: "string", required: true, description: "Visible package, item, module, ship class, system, POI, or base ID to inspect", positional: 0 }
2546
+ ]
2547
+ },
2444
2548
  "spacemolt/install_mod": {
2445
2549
  tool: "spacemolt",
2446
2550
  action: "install_mod",
@@ -2530,7 +2634,8 @@ var ACTIONS = {
2530
2634
  { name: "id", type: "string", required: false, description: "Recipe ID to recycle (the recycler consumes the recipe's outputs and returns a lossy fraction of its inputs). Use catalog with type=recipes to browse.", positional: 0 },
2531
2635
  { name: "job_id", type: "string", required: false, description: "Cancel this queued job (refunding its unconsumed escrow) instead of recycling. Setting job_id implies cancel." },
2532
2636
  { name: "job_ids", type: "string[]", required: false, description: "Bulk cancel: cancel many queued jobs in one action. Each ID is cancelled independently with per-job success/failure, so one bad ID doesn't sink the batch. Refunds the unconsumed escrow of every cancelled job." },
2533
- { name: "jobs", type: "Record<string, unknown>[]", required: false, description: "Bulk mode: recycle many recipes in one action. Each entry: {recipe_id, quantity, facility_id?, deliver_to?, source?}. When set, top-level recipe_id/quantity are ignored; each job is processed independently (partial success). Max 50." },
2637
+ { name: "jobs", type: "Record<string, unknown>[]", required: false, description: "Bulk mode: recycle many recipes in one action. Each entry: {recipe_id, quantity, facility_id?, preset?, deliver_to?, source?}. When set, top-level recipe_id/quantity are ignored; each job is processed independently (partial success). Max 50." },
2638
+ { name: "preset", type: '"fast" | "cheap" | "prefer_own"', required: false, description: "Auto-routing preset: 'fast' (fewest ticks, default) or 'cheap' (lowest fee) — both pick the best eligible recycler globally. Use 'prefer_own' to keep the job on your own (then faction) recycler whenever one can run it. 'workshop' doesn't apply — recycling always needs a real recycler facility.", enumValues: ["fast", "cheap", "prefer_own"] },
2534
2639
  { name: "quantity", type: "number", required: false, description: "Number of the recipe's output items to feed in and break down (default 1). Rounded up to a whole number of recycling runs.", positional: 1 },
2535
2640
  { name: "source", type: "string", required: false, description: "Where feedstock (and labor/rental credits) are pulled FROM. Same values as deliver_to. Defaults to deliver_to." }
2536
2641
  ]
@@ -2659,10 +2764,11 @@ var ACTIONS = {
2659
2764
  tool: "spacemolt",
2660
2765
  action: "unload_passenger",
2661
2766
  kind: "mutation",
2662
- summary: "Put a passenger (or everyone) off the ship at the current station",
2767
+ summary: "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",
2663
2768
  detailsType: "UnloadPassengerResponse",
2664
2769
  params: [
2665
- { name: "id", type: "string", required: true, description: 'Name (or citizen ID) of the passenger to put off the ship at the current station, or "all" to put every passenger off at once.', positional: 0 }
2770
+ { name: "id", type: "string", required: true, description: 'Name (or citizen ID) of the passenger to put off the ship at the current station, or "all" to put every passenger off at once.', positional: 0 },
2771
+ { name: "target", type: "string", required: false, description: `Optional connecting-flight handoff: "lounge" checks the passenger(s) into your faction's Transit Lounge here, or a ship ID/name transfers them to that docked ship (yours or a faction mate's, needs free berths). Fares and deadlines continue unchanged. Omit for a normal delivery/strand.` }
2666
2772
  ]
2667
2773
  },
2668
2774
  "spacemolt/unsubscribe_observation": {
@@ -2738,6 +2844,7 @@ function buildCommands(dispatch) {
2738
2844
  get_tax_estimate: bind("spacemolt", "get_tax_estimate"),
2739
2845
  get_version: bind("spacemolt", "get_version"),
2740
2846
  hunt: bind("spacemolt", "hunt"),
2847
+ inspect: bind("spacemolt", "inspect"),
2741
2848
  install_mod: bind("spacemolt", "install_mod"),
2742
2849
  jettison: bind("spacemolt", "jettison"),
2743
2850
  jump: bind("spacemolt", "jump"),
@@ -2774,10 +2881,12 @@ function buildCommands(dispatch) {
2774
2881
  spacemolt_battle: {
2775
2882
  advance: bind("spacemolt_battle", "advance"),
2776
2883
  engage: bind("spacemolt_battle", "engage"),
2884
+ log: bind("spacemolt_battle", "log"),
2777
2885
  reload: bind("spacemolt_battle", "reload"),
2778
2886
  retreat: bind("spacemolt_battle", "retreat"),
2779
2887
  stance: bind("spacemolt_battle", "stance"),
2780
2888
  status: bind("spacemolt_battle", "status"),
2889
+ summary: bind("spacemolt_battle", "summary"),
2781
2890
  target: bind("spacemolt_battle", "target")
2782
2891
  },
2783
2892
  spacemolt_citizenship: {
@@ -2807,9 +2916,11 @@ function buildCommands(dispatch) {
2807
2916
  buy_ship_license: bind("spacemolt_facility", "buy_ship_license"),
2808
2917
  cancel_listing: bind("spacemolt_facility", "cancel_listing"),
2809
2918
  deploy_outpost: bind("spacemolt_facility", "deploy_outpost"),
2919
+ disassemble: bind("spacemolt_facility", "disassemble"),
2810
2920
  dismantle: bind("spacemolt_facility", "dismantle"),
2811
2921
  facility_set_description: bind("spacemolt_facility", "facility_set_description"),
2812
2922
  faction_build: bind("spacemolt_facility", "faction_build"),
2923
+ faction_disassemble: bind("spacemolt_facility", "faction_disassemble"),
2813
2924
  faction_dismantle: bind("spacemolt_facility", "faction_dismantle"),
2814
2925
  faction_list: bind("spacemolt_facility", "faction_list"),
2815
2926
  faction_owned: bind("spacemolt_facility", "faction_owned"),
@@ -2827,7 +2938,9 @@ function buildCommands(dispatch) {
2827
2938
  personal_visit: bind("spacemolt_facility", "personal_visit"),
2828
2939
  remove_faction: bind("spacemolt_facility", "remove_faction"),
2829
2940
  remove_player: bind("spacemolt_facility", "remove_player"),
2941
+ repair: bind("spacemolt_facility", "repair"),
2830
2942
  set_access: bind("spacemolt_facility", "set_access"),
2943
+ set_auto_buy_fuel: bind("spacemolt_facility", "set_auto_buy_fuel"),
2831
2944
  set_build_policy: bind("spacemolt_facility", "set_build_policy"),
2832
2945
  set_description: bind("spacemolt_facility", "set_description"),
2833
2946
  set_market_fee: bind("spacemolt_facility", "set_market_fee"),
@@ -2948,7 +3061,6 @@ function buildCommands(dispatch) {
2948
3061
  refit_ship: bind("spacemolt_ship", "refit_ship"),
2949
3062
  rename_ship: bind("spacemolt_ship", "rename_ship"),
2950
3063
  scrap_ship: bind("spacemolt_ship", "scrap_ship"),
2951
- sell_ship: bind("spacemolt_ship", "sell_ship"),
2952
3064
  sell_ship_to_order: bind("spacemolt_ship", "sell_ship_to_order"),
2953
3065
  supply_commission: bind("spacemolt_ship", "supply_commission"),
2954
3066
  switch_ship: bind("spacemolt_ship", "switch_ship"),
@@ -2999,11 +3111,26 @@ function buildCommands(dispatch) {
2999
3111
  }
3000
3112
 
3001
3113
  // src/auth/clerk.ts
3002
- async function clerkFetch(fetchImpl, url, apiKey, init) {
3003
- const res = await fetchImpl(url, {
3004
- ...init,
3005
- headers: { accept: "application/json", authorization: `Bearer ${apiKey}`, ...init?.headers ?? {} }
3114
+ async function resolveAuthHeaders(auth) {
3115
+ if (auth.apiKey === undefined && auth.headers === undefined) {
3116
+ throw new Error("Clerk auth requires an `apiKey` or `headers`");
3117
+ }
3118
+ const headers = new Headers(auth.apiKey === undefined ? undefined : { authorization: `Bearer ${auth.apiKey}` });
3119
+ const extra = typeof auth.headers === "function" ? await auth.headers() : auth.headers;
3120
+ new Headers(extra).forEach((value, key) => {
3121
+ headers.set(key, value);
3122
+ });
3123
+ return headers;
3124
+ }
3125
+ async function clerkFetch(fetchImpl, url, auth, init) {
3126
+ const headers = new Headers({ accept: "application/json" });
3127
+ (await resolveAuthHeaders(auth)).forEach((value, key) => {
3128
+ headers.set(key, value);
3129
+ });
3130
+ new Headers(init?.headers).forEach((value, key) => {
3131
+ headers.set(key, value);
3006
3132
  });
3133
+ const res = await fetchImpl(url, { ...init, headers });
3007
3134
  if (!res.ok) {
3008
3135
  const body = await res.text().catch(() => "");
3009
3136
  throw new Error(`${init?.method ?? "GET"} ${url} -> ${res.status} ${res.statusText}${body ? `: ${body}` : ""}`);
@@ -3012,8 +3139,10 @@ async function clerkFetch(fetchImpl, url, apiKey, init) {
3012
3139
  }
3013
3140
  async function mintWsToken(opts) {
3014
3141
  const base = opts.httpBaseUrl.replace(/\/$/, "");
3015
- const data = await clerkFetch(opts.fetchImpl ?? fetch, `${base}/api/player/${encodeURIComponent(opts.playerId)}/ws-token`, opts.apiKey, { method: "POST" });
3016
- if (!data.token)
3142
+ const data = requireRecord(await clerkFetch(opts.fetchImpl ?? fetch, `${base}/api/player/${encodeURIComponent(opts.playerId)}/ws-token`, opts, {
3143
+ method: "POST"
3144
+ }), "ws-token response");
3145
+ if (typeof data.token !== "string" || !data.token)
3017
3146
  throw new Error("ws-token response had no token");
3018
3147
  return data.token;
3019
3148
  }
@@ -3024,18 +3153,34 @@ class ClerkSource {
3024
3153
  httpBaseUrl;
3025
3154
  constructor(opts) {
3026
3155
  this.opts = opts;
3156
+ if (opts.apiKey === undefined && opts.headers === undefined) {
3157
+ throw new Error("ClerkSource requires an `apiKey` or `headers`");
3158
+ }
3027
3159
  this.fetchImpl = opts.fetchImpl ?? fetch;
3028
3160
  this.httpBaseUrl = opts.httpBaseUrl.replace(/\/$/, "");
3029
3161
  }
3030
3162
  get apiKey() {
3031
3163
  return this.opts.apiKey;
3032
3164
  }
3165
+ async fetchRegistration() {
3166
+ const data = requireRecord(await clerkFetch(this.fetchImpl, `${this.httpBaseUrl}/api/registration-code`, this.opts), "registration-code response");
3167
+ const players = Array.isArray(data.players) ? data.players.filter((player) => isRecord(player) && typeof player.id === "string" && typeof player.username === "string" && typeof player.empire === "string" && typeof player.hidden === "boolean") : [];
3168
+ return {
3169
+ registrationCode: typeof data.registration_code === "string" ? data.registration_code : "",
3170
+ players
3171
+ };
3172
+ }
3033
3173
  async listPlayers() {
3034
- const data = await clerkFetch(this.fetchImpl, `${this.httpBaseUrl}/api/registration-code`, this.opts.apiKey);
3035
- return data.players ?? [];
3174
+ return (await this.fetchRegistration()).players;
3036
3175
  }
3037
3176
  mintWsToken(playerId) {
3038
- return mintWsToken({ httpBaseUrl: this.httpBaseUrl, apiKey: this.opts.apiKey, playerId, fetchImpl: this.fetchImpl });
3177
+ return mintWsToken({
3178
+ httpBaseUrl: this.httpBaseUrl,
3179
+ apiKey: this.opts.apiKey,
3180
+ headers: this.opts.headers,
3181
+ playerId,
3182
+ fetchImpl: this.fetchImpl
3183
+ });
3039
3184
  }
3040
3185
  }
3041
3186
 
@@ -3112,7 +3257,7 @@ class EventStream {
3112
3257
  if (next)
3113
3258
  next({ value, done: false });
3114
3259
  else
3115
- this.buffer.push(value);
3260
+ this.buffer.push({ value });
3116
3261
  }
3117
3262
  end() {
3118
3263
  if (this.ended)
@@ -3123,8 +3268,9 @@ class EventStream {
3123
3268
  this.waiting.length = 0;
3124
3269
  }
3125
3270
  next() {
3126
- if (this.buffer.length)
3127
- return Promise.resolve({ value: this.buffer.shift(), done: false });
3271
+ const buffered = this.buffer.shift();
3272
+ if (buffered)
3273
+ return Promise.resolve({ value: buffered.value, done: false });
3128
3274
  if (this.ended)
3129
3275
  return Promise.resolve({ value: undefined, done: true });
3130
3276
  return new Promise((resolve) => this.waiting.push(resolve));
@@ -3150,8 +3296,9 @@ class TypedEmitter {
3150
3296
  set = new Set;
3151
3297
  this.handlers.set(type, set);
3152
3298
  }
3153
- set.add(handler);
3154
- return () => set.delete(handler);
3299
+ const wrapped = (payload) => handler(payload);
3300
+ set.add(wrapped);
3301
+ return () => set.delete(wrapped);
3155
3302
  }
3156
3303
  onAny(handler) {
3157
3304
  this.anyHandlers.add(handler);
@@ -3315,8 +3462,12 @@ class ObservationCache {
3315
3462
  }
3316
3463
 
3317
3464
  // src/state/cache.ts
3318
- function asRecord(value) {
3319
- return value;
3465
+ function copySection(target, source, section) {
3466
+ const value = source[section];
3467
+ if (value === undefined)
3468
+ return false;
3469
+ target[section] = value;
3470
+ return true;
3320
3471
  }
3321
3472
 
3322
3473
  class StateCache {
@@ -3324,24 +3475,18 @@ class StateCache {
3324
3475
  seed(snapshot) {
3325
3476
  const next = {};
3326
3477
  const changed = [];
3327
- const src = asRecord(snapshot);
3328
3478
  for (const section of STATE_SECTIONS) {
3329
- if (src[section] !== undefined) {
3330
- asRecord(next)[section] = src[section];
3479
+ if (copySection(next, snapshot, section))
3331
3480
  changed.push(section);
3332
- }
3333
3481
  }
3334
3482
  this.state = next;
3335
3483
  return changed;
3336
3484
  }
3337
3485
  applyDelta(delta) {
3338
3486
  const changed = [];
3339
- const src = asRecord(delta);
3340
3487
  for (const section of STATE_SECTIONS) {
3341
- if (src[section] !== undefined) {
3342
- asRecord(this.state)[section] = src[section];
3488
+ if (copySection(this.state, delta, section))
3343
3489
  changed.push(section);
3344
- }
3345
3490
  }
3346
3491
  return changed;
3347
3492
  }
@@ -3349,7 +3494,7 @@ class StateCache {
3349
3494
  const current = this.state[section];
3350
3495
  if (current === undefined)
3351
3496
  return [];
3352
- asRecord(this.state)[section] = { ...asRecord(current), ...patch };
3497
+ this.state[section] = { ...current, ...patch };
3353
3498
  return [section];
3354
3499
  }
3355
3500
  snapshot() {
@@ -3388,13 +3533,12 @@ class StateCache {
3388
3533
  function extractPendingAck(structured) {
3389
3534
  if (!structured)
3390
3535
  return;
3391
- if (structured["pending"] === true) {
3392
- return { command: String(structured["command"] ?? ""), message: String(structured["message"] ?? "") };
3536
+ if (structured.pending === true) {
3537
+ return { command: String(structured.command ?? ""), message: String(structured.message ?? "") };
3393
3538
  }
3394
- const details = structured["details"];
3395
- if (details && typeof details === "object" && details["pending"] === true) {
3396
- const d = details;
3397
- return { command: String(d["command"] ?? ""), message: String(d["message"] ?? "") };
3539
+ const details = structured.details;
3540
+ if (isRecord(details) && details.pending === true) {
3541
+ return { command: String(details.command ?? ""), message: String(details.message ?? "") };
3398
3542
  }
3399
3543
  return;
3400
3544
  }
@@ -3426,6 +3570,8 @@ class Correlator {
3426
3570
  return false;
3427
3571
  switch (frame.type) {
3428
3572
  case "result": {
3573
+ if (!isResultFrame(frame))
3574
+ return this.rejectInvalidResponse(requestId, pending, frame.type);
3429
3575
  const payload = frame.payload;
3430
3576
  if (pending.kind === "query") {
3431
3577
  this.settle(requestId);
@@ -3437,7 +3583,7 @@ class Correlator {
3437
3583
  if (!ack) {
3438
3584
  this.settle(requestId);
3439
3585
  pending.resolve({
3440
- command: String(structured?.["command"] ?? ""),
3586
+ command: String(structured?.command ?? ""),
3441
3587
  tick: 0,
3442
3588
  delta: { details: structured }
3443
3589
  });
@@ -3450,6 +3596,8 @@ class Correlator {
3450
3596
  case "action_result": {
3451
3597
  if (pending.kind !== "mutation")
3452
3598
  return true;
3599
+ if (!isActionResultFrame(frame))
3600
+ return this.rejectInvalidResponse(requestId, pending, frame.type);
3453
3601
  const payload = frame.payload;
3454
3602
  this.settle(requestId);
3455
3603
  pending.resolve({
@@ -3462,11 +3610,15 @@ class Correlator {
3462
3610
  return true;
3463
3611
  }
3464
3612
  case "action_error": {
3613
+ if (!isActionErrorFrame(frame))
3614
+ return this.rejectInvalidResponse(requestId, pending, frame.type);
3465
3615
  this.settle(requestId);
3466
3616
  pending.reject(errorFromActionFrame(frame));
3467
3617
  return true;
3468
3618
  }
3469
3619
  case "error": {
3620
+ if (!isErrorFrame(frame))
3621
+ return this.rejectInvalidResponse(requestId, pending, frame.type);
3470
3622
  this.settle(requestId);
3471
3623
  pending.reject(errorFromFrame(frame));
3472
3624
  return true;
@@ -3483,10 +3635,43 @@ class Correlator {
3483
3635
  settle(requestId) {
3484
3636
  this.pending.delete(requestId);
3485
3637
  }
3638
+ rejectInvalidResponse(requestId, pending, frameType) {
3639
+ this.settle(requestId);
3640
+ pending.reject(new SpacemoltError("invalid_response", `Malformed ${frameType} frame for request ${requestId}`));
3641
+ return true;
3642
+ }
3486
3643
  }
3487
3644
 
3488
3645
  // src/transport/socket.ts
3489
- var defaultFactory = (url) => new WebSocket(url);
3646
+ class NativeWebSocketAdapter {
3647
+ socket;
3648
+ constructor(url) {
3649
+ this.socket = new WebSocket(url);
3650
+ }
3651
+ send(data) {
3652
+ this.socket.send(data);
3653
+ }
3654
+ close(code, reason) {
3655
+ this.socket.close(code, reason);
3656
+ }
3657
+ addEventListener(...args) {
3658
+ switch (args[0]) {
3659
+ case "open":
3660
+ this.socket.addEventListener("open", () => args[1]());
3661
+ break;
3662
+ case "message":
3663
+ this.socket.addEventListener("message", (event) => args[1]({ data: event.data }));
3664
+ break;
3665
+ case "close":
3666
+ this.socket.addEventListener("close", (event) => args[1]({ code: event.code, reason: event.reason }));
3667
+ break;
3668
+ case "error":
3669
+ this.socket.addEventListener("error", (event) => args[1](event));
3670
+ break;
3671
+ }
3672
+ }
3673
+ }
3674
+ var defaultFactory = (url) => new NativeWebSocketAdapter(url);
3490
3675
 
3491
3676
  class Socket {
3492
3677
  opts;
@@ -3542,7 +3727,10 @@ class Socket {
3542
3727
  continue;
3543
3728
  let frame;
3544
3729
  try {
3545
- frame = JSON.parse(line);
3730
+ const parsed = JSON.parse(line);
3731
+ if (!isRecord(parsed) || typeof parsed.type !== "string" || parsed.request_id !== undefined && typeof parsed.request_id !== "string")
3732
+ throw new TypeError("frame must be an object with a type");
3733
+ frame = { ...parsed, type: parsed.type };
3546
3734
  } catch (err) {
3547
3735
  const head = line.slice(0, 200);
3548
3736
  const tail = line.length > 400 ? line.slice(-200) : "";
@@ -3590,11 +3778,18 @@ function normalizeReconnect(opt) {
3590
3778
  };
3591
3779
  }
3592
3780
  function retryAfterMs(err) {
3593
- const fromDetails = typeof err.details?.["retry_after"] === "number" ? err.details["retry_after"] : undefined;
3781
+ const retryAfter = err.details?.retry_after;
3782
+ const fromDetails = typeof retryAfter === "number" ? retryAfter : undefined;
3594
3783
  const match = err.message.match(/retry in (\d+)\s*second/i);
3595
3784
  const seconds = fromDetails ?? (match ? Number(match[1]) : undefined);
3596
3785
  return Math.max(250, (seconds ?? 1) * 1000);
3597
3786
  }
3787
+ function requireStructuredContent(result, command) {
3788
+ if (result.structuredContent === undefined) {
3789
+ throw new SpacemoltError("invalid_response", `${command} returned no structured content`);
3790
+ }
3791
+ return result.structuredContent;
3792
+ }
3598
3793
 
3599
3794
  class Account {
3600
3795
  socket;
@@ -3619,10 +3814,11 @@ class Account {
3619
3814
  _welcome = null;
3620
3815
  _authenticated = false;
3621
3816
  _loginPayload = null;
3817
+ _currentTick = 0;
3622
3818
  _commands = null;
3623
3819
  welcomeWaiter = null;
3624
3820
  pendingAuth = null;
3625
- stateListener = null;
3821
+ stateListeners = new Set;
3626
3822
  userClosing = false;
3627
3823
  reconnecting = false;
3628
3824
  mutationLane = Promise.resolve();
@@ -3631,9 +3827,9 @@ class Account {
3631
3827
  observationSubscribedState = false;
3632
3828
  observationActiveScanState = false;
3633
3829
  subscribedObservationPoiId;
3634
- reconnectedListener = null;
3635
- reconnectingListener = null;
3636
- disconnectedListener = null;
3830
+ reconnectedListeners = new Set;
3831
+ reconnectingListeners = new Set;
3832
+ disconnectedListeners = new Set;
3637
3833
  constructor(opts = {}) {
3638
3834
  this.seedState = opts.seedState ?? true;
3639
3835
  this.url = opts.url ?? DEFAULT_URL;
@@ -3648,9 +3844,9 @@ class Account {
3648
3844
  this._id = opts.id;
3649
3845
  this.reconnectConfig = normalizeReconnect(opts.reconnect);
3650
3846
  this.makeSocket();
3651
- this.emitter.on("market_update", (p) => this.marketCache.applyUpdate(p));
3652
- this.emitter.on("observation_update", (p) => {
3653
- this.observationCache.applyUpdate(p);
3847
+ this.emitter.on("market_update", (payload) => this.marketCache.applyUpdate(payload));
3848
+ this.emitter.on("observation_update", (payload) => {
3849
+ this.observationCache.applyUpdate(payload);
3654
3850
  this.bridgeObservationToLocation();
3655
3851
  });
3656
3852
  }
@@ -3698,6 +3894,13 @@ class Account {
3698
3894
  get welcome() {
3699
3895
  return this._welcome;
3700
3896
  }
3897
+ get currentTick() {
3898
+ return this._currentTick;
3899
+ }
3900
+ observeTick(tick) {
3901
+ if (typeof tick === "number" && tick > this._currentTick)
3902
+ this._currentTick = tick;
3903
+ }
3701
3904
  get authenticated() {
3702
3905
  return this._authenticated;
3703
3906
  }
@@ -3788,19 +3991,32 @@ class Account {
3788
3991
  }
3789
3992
  }
3790
3993
  async refresh() {
3791
- const res = await this.query("spacemolt", "get_status");
3792
- const snapshot = res.structuredContent;
3793
- if (snapshot) {
3794
- const changed = this.cache.seed(snapshot);
3795
- if (changed.includes("location"))
3796
- this.checkSubscriptionsAgainstLocation();
3797
- if (changed.length)
3798
- this.stateListener?.(changed);
3799
- }
3994
+ const result = await this.commands.spacemolt.get_status();
3995
+ const snapshot = requireStructuredContent(result, "spacemolt/get_status");
3996
+ const changed = this.cache.seed(snapshot);
3997
+ if (changed.includes("location"))
3998
+ this.checkSubscriptionsAgainstLocation();
3999
+ if (changed.length)
4000
+ this.emitStateChange(changed);
3800
4001
  return this.cache.snapshot();
3801
4002
  }
3802
4003
  onStateChange(listener) {
3803
- this.stateListener = listener;
4004
+ this.stateListeners.add(listener);
4005
+ return () => {
4006
+ this.stateListeners.delete(listener);
4007
+ };
4008
+ }
4009
+ notifyListeners(listeners, label, ...args) {
4010
+ for (const listener of listeners) {
4011
+ try {
4012
+ listener(...args);
4013
+ } catch (err) {
4014
+ console.warn(`[spacemolt] ${label} listener threw: ${err}`);
4015
+ }
4016
+ }
4017
+ }
4018
+ emitStateChange(changed) {
4019
+ this.notifyListeners(this.stateListeners, "onStateChange", changed);
3804
4020
  }
3805
4021
  async logout() {
3806
4022
  await this.query("spacemolt_auth", "logout");
@@ -3850,7 +4066,7 @@ class Account {
3850
4066
  }, (err) => {
3851
4067
  if (timer)
3852
4068
  clearTimeout(timer);
3853
- reject(err);
4069
+ reject(err instanceof Error ? err : new Error(String(err)));
3854
4070
  });
3855
4071
  });
3856
4072
  }
@@ -3871,13 +4087,12 @@ class Account {
3871
4087
  return this.emitter.anyStream();
3872
4088
  }
3873
4089
  async subscribeMarket() {
3874
- const res = await this.query("spacemolt_market", "subscribe_market");
3875
- const snapshot = res.structuredContent;
3876
- if (snapshot)
3877
- this.marketCache.seed(snapshot);
4090
+ const result = await this.commands.spacemolt_market.subscribe_market();
4091
+ const snapshot = requireStructuredContent(result, "spacemolt_market/subscribe_market");
4092
+ this.marketCache.seed(snapshot);
3878
4093
  this.marketSubscribedState = true;
3879
- this.subscribedMarketBaseId = snapshot?.base_id;
3880
- return snapshot ?? {};
4094
+ this.subscribedMarketBaseId = snapshot.base_id;
4095
+ return snapshot;
3881
4096
  }
3882
4097
  async unsubscribeMarket() {
3883
4098
  const baseId = this.subscribedMarketBaseId ?? this.location?.docked_at ?? this.marketCache.bases()[0];
@@ -3891,16 +4106,14 @@ class Account {
3891
4106
  return this.marketCache.book(baseId);
3892
4107
  }
3893
4108
  async subscribeObservation(activeScan = false) {
3894
- const res = await this.query("spacemolt", "subscribe_observation", activeScan ? { active_scan: true } : undefined);
3895
- const snapshot = res.structuredContent;
3896
- if (snapshot) {
3897
- this.observationCache.seed(snapshot);
3898
- this.bridgeObservationToLocation();
3899
- }
4109
+ const result = await this.commands.spacemolt.subscribe_observation(activeScan ? { active_scan: true } : undefined);
4110
+ const snapshot = requireStructuredContent(result, "spacemolt/subscribe_observation");
4111
+ this.observationCache.seed(snapshot);
4112
+ this.bridgeObservationToLocation();
3900
4113
  this.observationSubscribedState = true;
3901
4114
  this.observationActiveScanState = activeScan;
3902
- this.subscribedObservationPoiId = snapshot?.poi_id;
3903
- return snapshot ?? {};
4115
+ this.subscribedObservationPoiId = snapshot.poi_id;
4116
+ return snapshot;
3904
4117
  }
3905
4118
  async unsubscribeObservation() {
3906
4119
  this.observationSubscribedState = false;
@@ -3918,7 +4131,7 @@ class Account {
3918
4131
  nearby_player_count: view.nearby.size
3919
4132
  });
3920
4133
  if (changed.length)
3921
- this.stateListener?.(changed);
4134
+ this.emitStateChange(changed);
3922
4135
  }
3923
4136
  observation() {
3924
4137
  return this.observationCache.current();
@@ -3940,13 +4153,22 @@ class Account {
3940
4153
  this.socket.close();
3941
4154
  }
3942
4155
  onReconnected(listener) {
3943
- this.reconnectedListener = listener;
4156
+ this.reconnectedListeners.add(listener);
4157
+ return () => {
4158
+ this.reconnectedListeners.delete(listener);
4159
+ };
3944
4160
  }
3945
4161
  onReconnecting(listener) {
3946
- this.reconnectingListener = listener;
4162
+ this.reconnectingListeners.add(listener);
4163
+ return () => {
4164
+ this.reconnectingListeners.delete(listener);
4165
+ };
3947
4166
  }
3948
4167
  onDisconnected(listener) {
3949
- this.disconnectedListener = listener;
4168
+ this.disconnectedListeners.add(listener);
4169
+ return () => {
4170
+ this.disconnectedListeners.delete(listener);
4171
+ };
3950
4172
  }
3951
4173
  async authExchange(action, payload) {
3952
4174
  const authPromise = new Promise((resolve, reject) => {
@@ -3989,19 +4211,28 @@ class Account {
3989
4211
  return `r${++this.requestSeq}`;
3990
4212
  }
3991
4213
  routeFrame(frame) {
4214
+ if (isRecord(frame.payload))
4215
+ this.observeTick(frame.payload.tick);
3992
4216
  switch (frame.type) {
3993
4217
  case "welcome": {
4218
+ if (!isWelcomeFrame(frame))
4219
+ return this.warnMalformedFrame(frame);
3994
4220
  const payload = frame.payload;
3995
4221
  this._welcome = payload;
4222
+ this.observeTick(payload.current_tick);
3996
4223
  this.welcomeWaiter?.resolve(payload);
3997
4224
  this.welcomeWaiter = null;
3998
4225
  return;
3999
4226
  }
4000
4227
  case "registered":
4228
+ if (!isRegisteredFrame(frame))
4229
+ return this.warnMalformedFrame(frame);
4001
4230
  if (this.pendingAuth)
4002
4231
  this.pendingAuth.registered = frame.payload;
4003
4232
  return;
4004
4233
  case "logged_in": {
4234
+ if (!isLoggedInFrame(frame))
4235
+ return this.warnMalformedFrame(frame);
4005
4236
  const payload = frame.payload;
4006
4237
  const auth = this.pendingAuth;
4007
4238
  if (auth) {
@@ -4014,18 +4245,18 @@ class Account {
4014
4245
  return;
4015
4246
  }
4016
4247
  case "action_result": {
4248
+ if (!isActionResultFrame(frame)) {
4249
+ if (!this.correlator.handle(frame))
4250
+ this.warnMalformedFrame(frame);
4251
+ return;
4252
+ }
4017
4253
  const delta = frame.payload.result;
4018
4254
  if (delta) {
4019
4255
  const changed = this.cache.applyDelta(delta);
4020
4256
  if (changed.includes("location"))
4021
4257
  this.checkSubscriptionsAgainstLocation();
4022
- if (changed.length) {
4023
- try {
4024
- this.stateListener?.(changed);
4025
- } catch (err) {
4026
- console.warn(`[spacemolt] onStateChange listener threw: ${err}`);
4027
- }
4028
- }
4258
+ if (changed.length)
4259
+ this.emitStateChange(changed);
4029
4260
  }
4030
4261
  if (!this.correlator.handle(frame)) {
4031
4262
  console.warn(`[spacemolt] action_result for ${frame.request_id ?? "?"} arrived with no matching pending mutation (already timed out?)`);
@@ -4039,6 +4270,11 @@ class Account {
4039
4270
  this.emitter.emit(frame);
4040
4271
  return;
4041
4272
  case "error": {
4273
+ if (!isErrorFrame(frame)) {
4274
+ if (!this.correlator.handle(frame))
4275
+ this.warnMalformedFrame(frame);
4276
+ return;
4277
+ }
4042
4278
  if (this.correlator.handle(frame))
4043
4279
  return;
4044
4280
  const auth = this.pendingAuth;
@@ -4054,6 +4290,9 @@ class Account {
4054
4290
  this.emitter.emit(frame);
4055
4291
  }
4056
4292
  }
4293
+ warnMalformedFrame(frame) {
4294
+ console.warn(`[spacemolt] dropped malformed ${frame.type} frame`);
4295
+ }
4057
4296
  handleClose(err) {
4058
4297
  this._authenticated = false;
4059
4298
  this.correlator.rejectAll(err);
@@ -4071,7 +4310,7 @@ class Account {
4071
4310
  if (this.shouldReconnect(err)) {
4072
4311
  this.reconnectLoop(err);
4073
4312
  } else if (!this.userClosing) {
4074
- this.disconnectedListener?.(err);
4313
+ this.notifyListeners(this.disconnectedListeners, "onDisconnected", err);
4075
4314
  }
4076
4315
  }
4077
4316
  shouldReconnect(err) {
@@ -4090,7 +4329,7 @@ class Account {
4090
4329
  const cfg = this.reconnectConfig;
4091
4330
  const retryAfterMs2 = retryAfterMsFromClose(err);
4092
4331
  for (let attempt = 1;attempt <= cfg.maxRetries; attempt++) {
4093
- this.reconnectingListener?.(attempt);
4332
+ this.notifyListeners(this.reconnectingListeners, "onReconnecting", attempt);
4094
4333
  const backoff = attempt === 1 && retryAfterMs2 !== undefined ? retryAfterMs2 : Math.min(cfg.baseDelayMs * 2 ** (attempt - 1), cfg.maxDelayMs);
4095
4334
  await delay(backoff);
4096
4335
  if (this.userClosing) {
@@ -4100,12 +4339,12 @@ class Account {
4100
4339
  try {
4101
4340
  await this.reconnectOnce();
4102
4341
  this.reconnecting = false;
4103
- this.reconnectedListener?.();
4342
+ this.notifyListeners(this.reconnectedListeners, "onReconnected");
4104
4343
  return;
4105
4344
  } catch {}
4106
4345
  }
4107
4346
  this.reconnecting = false;
4108
- this.disconnectedListener?.(err);
4347
+ this.notifyListeners(this.disconnectedListeners, "onDisconnected", err);
4109
4348
  }
4110
4349
  async reconnectOnce() {
4111
4350
  if (!this.credentialsProvider)
@@ -4171,27 +4410,51 @@ class MemoryCredentialStore {
4171
4410
  }
4172
4411
 
4173
4412
  // src/data/catalog.ts
4174
- async function fetchCatalog(httpBaseUrl) {
4413
+ function catalogEntries(value) {
4414
+ return Array.isArray(value) ? value.filter((entry) => isRecord(entry)) : [];
4415
+ }
4416
+ function normalizeCatalog(value) {
4417
+ const data = requireRecord(value, "catalog response");
4418
+ return {
4419
+ version: typeof data.version === "string" ? data.version : undefined,
4420
+ ships: catalogEntries(data.ships),
4421
+ items: catalogEntries(data.items),
4422
+ recipes: catalogEntries(data.recipes),
4423
+ skills: catalogEntries(data.skills),
4424
+ facilities: catalogEntries(data.facilities)
4425
+ };
4426
+ }
4427
+ async function fetchCatalogConditional(httpBaseUrl, etag) {
4175
4428
  const url = `${httpBaseUrl.replace(/\/$/, "")}/api/catalog.json`;
4176
- const res = await fetch(url, { headers: { accept: "application/json" } });
4429
+ const headers = { accept: "application/json" };
4430
+ if (etag)
4431
+ headers["if-none-match"] = etag;
4432
+ const res = await fetch(url, { headers });
4433
+ if (res.status === 304)
4434
+ return { notModified: true, etag };
4177
4435
  if (!res.ok)
4178
4436
  throw new Error(`GET ${url} -> ${res.status} ${res.statusText}`);
4179
4437
  const data = await res.json();
4180
4438
  return {
4181
- version: data.version,
4182
- ships: data.ships ?? [],
4183
- items: data.items ?? [],
4184
- recipes: data.recipes ?? [],
4185
- skills: data.skills ?? [],
4186
- facilities: data.facilities ?? []
4439
+ notModified: false,
4440
+ catalog: normalizeCatalog(data),
4441
+ etag: res.headers?.get?.("etag") ?? undefined
4187
4442
  };
4188
4443
  }
4444
+ async function fetchCatalog(httpBaseUrl) {
4445
+ const { catalog } = await fetchCatalogConditional(httpBaseUrl);
4446
+ if (!catalog)
4447
+ throw new Error("unconditional catalog fetch returned no catalog");
4448
+ return catalog;
4449
+ }
4189
4450
 
4190
4451
  class CatalogCache {
4191
4452
  catalog;
4453
+ etag;
4192
4454
  indexes;
4193
- constructor(catalog) {
4455
+ constructor(catalog, etag) {
4194
4456
  this.catalog = catalog;
4457
+ this.etag = etag;
4195
4458
  this.indexes = {
4196
4459
  ships: index(catalog.ships),
4197
4460
  items: index(catalog.items),
@@ -4201,7 +4464,18 @@ class CatalogCache {
4201
4464
  };
4202
4465
  }
4203
4466
  static async load(httpBaseUrl) {
4204
- return new CatalogCache(await fetchCatalog(httpBaseUrl));
4467
+ const { catalog, etag } = await fetchCatalogConditional(httpBaseUrl);
4468
+ if (!catalog)
4469
+ throw new Error("unconditional catalog fetch returned no catalog");
4470
+ return new CatalogCache(catalog, etag);
4471
+ }
4472
+ async revalidate(httpBaseUrl) {
4473
+ const result = await fetchCatalogConditional(httpBaseUrl, this.etag);
4474
+ if (result.notModified)
4475
+ return this;
4476
+ if (!result.catalog)
4477
+ throw new Error("modified catalog response returned no catalog");
4478
+ return new CatalogCache(result.catalog, result.etag);
4205
4479
  }
4206
4480
  get version() {
4207
4481
  return this.catalog.version;
@@ -4253,8 +4527,10 @@ async function fetchMap(httpBaseUrl) {
4253
4527
  const res = await fetch(url, { headers: { accept: "application/json" } });
4254
4528
  if (!res.ok)
4255
4529
  throw new Error(`GET ${url} -> ${res.status} ${res.statusText}`);
4256
- const data = await res.json();
4257
- return { systems: data.systems ?? [], empires: data.empires ?? {} };
4530
+ const data = requireRecord(await res.json(), "map response");
4531
+ const systems = Array.isArray(data.systems) ? data.systems.filter((system) => isRecord(system)) : [];
4532
+ const empires = isRecord(data.empires) ? Object.fromEntries(Object.entries(data.empires).filter((entry) => typeof entry[1] === "string")) : {};
4533
+ return { systems, empires };
4258
4534
  }
4259
4535
 
4260
4536
  class MapCache {
@@ -4303,6 +4579,7 @@ class SpacemoltClient {
4303
4579
  store;
4304
4580
  connected = new Map;
4305
4581
  catalogCache;
4582
+ catalogFetchedAt = 0;
4306
4583
  mapCache;
4307
4584
  clerkSource;
4308
4585
  accountConnectedListeners = new Set;
@@ -4343,8 +4620,13 @@ class SpacemoltClient {
4343
4620
  return this.opts.httpBaseUrl ?? (this.opts.url ? httpBaseFromWs(this.opts.url) : DEFAULT_HTTP_BASE);
4344
4621
  }
4345
4622
  async catalog(force = false) {
4346
- if (force || !this.catalogCache)
4347
- this.catalogCache = await CatalogCache.load(this.httpBaseUrl);
4623
+ const maxAgeMs = this.opts.catalogMaxAgeMs ?? 3600000;
4624
+ const cached = this.catalogCache;
4625
+ const fresh = cached && !force && Date.now() - this.catalogFetchedAt < maxAgeMs;
4626
+ if (fresh)
4627
+ return cached;
4628
+ this.catalogCache = !this.catalogCache || force ? await CatalogCache.load(this.httpBaseUrl) : await this.catalogCache.revalidate(this.httpBaseUrl);
4629
+ this.catalogFetchedAt = Date.now();
4348
4630
  return this.catalogCache;
4349
4631
  }
4350
4632
  async map(force = false) {
@@ -4366,16 +4648,22 @@ class SpacemoltClient {
4366
4648
  async register(params) {
4367
4649
  const account = this.createAccount(params.username);
4368
4650
  this.connected.set(params.username, account);
4369
- await account.connect();
4370
- const result = await account.register(params);
4371
- await this.store.put({
4372
- id: params.username,
4373
- credentials: { kind: "login", username: params.username, password: result.password },
4374
- playerId: result.player_id
4375
- });
4376
- account.onDisconnected((err) => this.handleAccountDisconnected(params.username, account, err));
4377
- this.notifyAccountConnected(account);
4378
- return { account, result };
4651
+ try {
4652
+ await account.connect();
4653
+ const result = await account.register(params);
4654
+ await this.store.put({
4655
+ id: params.username,
4656
+ credentials: { kind: "login", username: params.username, password: result.password },
4657
+ playerId: result.player_id
4658
+ });
4659
+ account.onDisconnected((err) => this.handleAccountDisconnected(params.username, account, err));
4660
+ this.notifyAccountConnected(account);
4661
+ return { account, result };
4662
+ } catch (error) {
4663
+ account.close();
4664
+ this.connected.delete(params.username);
4665
+ throw error;
4666
+ }
4379
4667
  }
4380
4668
  connectRetryConfig() {
4381
4669
  const retryOpt = this.opts.connectRetry ?? true;
@@ -4428,10 +4716,10 @@ class SpacemoltClient {
4428
4716
  return this.connectIds(stored.map((s) => s.id), opts.onConnect);
4429
4717
  }
4430
4718
  async listOwnedPlayers() {
4431
- return this.requireClerkSource().listPlayers();
4719
+ return this.requireClerkAuth().source.listPlayers();
4432
4720
  }
4433
4721
  async connectOwned(opts = {}) {
4434
- const source = this.requireClerkSource();
4722
+ const { source, apiKey } = this.requireClerkAuth();
4435
4723
  const players = await source.listPlayers();
4436
4724
  const selected = opts.filter ? players.filter(opts.filter) : players;
4437
4725
  const ids = [];
@@ -4440,7 +4728,7 @@ class SpacemoltClient {
4440
4728
  id: player.username,
4441
4729
  credentials: {
4442
4730
  kind: "clerk",
4443
- apiKey: source.apiKey,
4731
+ apiKey,
4444
4732
  playerId: player.id,
4445
4733
  httpBaseUrl: source.httpBaseUrl
4446
4734
  },
@@ -4450,18 +4738,17 @@ class SpacemoltClient {
4450
4738
  }
4451
4739
  return this.connectIds(ids, opts.onConnect);
4452
4740
  }
4453
- requireClerkSource() {
4454
- if (!this.clerkSource) {
4455
- if (!this.opts.clerkApiKey) {
4456
- throw new Error("connectOwned/listOwnedPlayers require `clerkApiKey` in SpacemoltClientOptions");
4457
- }
4458
- this.clerkSource = new ClerkSource({
4459
- apiKey: this.opts.clerkApiKey,
4460
- httpBaseUrl: this.httpBaseUrl,
4461
- fetchImpl: this.opts.fetchImpl
4462
- });
4741
+ requireClerkAuth() {
4742
+ const apiKey = this.opts.clerkApiKey;
4743
+ if (!apiKey) {
4744
+ throw new Error("connectOwned/listOwnedPlayers require `clerkApiKey` in SpacemoltClientOptions");
4463
4745
  }
4464
- return this.clerkSource;
4746
+ this.clerkSource ??= new ClerkSource({
4747
+ apiKey,
4748
+ httpBaseUrl: this.httpBaseUrl,
4749
+ fetchImpl: this.opts.fetchImpl
4750
+ });
4751
+ return { source: this.clerkSource, apiKey };
4465
4752
  }
4466
4753
  async connectIds(ids, onConnect) {
4467
4754
  const accounts = [];
@@ -4505,6 +4792,8 @@ class SpacemoltClient {
4505
4792
  }
4506
4793
  isFirst = false;
4507
4794
  const task = this.rateLimitedQueue.shift();
4795
+ if (!task)
4796
+ break;
4508
4797
  ranInCurrentBatch++;
4509
4798
  await task();
4510
4799
  }
@@ -4604,6 +4893,7 @@ var TYPED_NOTIFICATION_TYPES = [
4604
4893
  "reconnected",
4605
4894
  "scan_detected",
4606
4895
  "skill_level_up",
4896
+ "station_repaired",
4607
4897
  "trade_cancelled",
4608
4898
  "trade_complete",
4609
4899
  "trade_declined",
@@ -4612,8 +4902,16 @@ var TYPED_NOTIFICATION_TYPES = [
4612
4902
  export {
4613
4903
  retryAfterMsFromClose,
4614
4904
  mintWsToken,
4905
+ isWelcomeFrame,
4906
+ isResultFrame,
4907
+ isRegisteredFrame,
4908
+ isLoggedInFrame,
4909
+ isErrorFrame,
4910
+ isActionResultFrame,
4911
+ isActionErrorFrame,
4615
4912
  httpBaseFromWs,
4616
4913
  fetchMap,
4914
+ fetchCatalogConditional,
4617
4915
  fetchCatalog,
4618
4916
  buildCommands,
4619
4917
  TypedEmitter,