@shipload/sdk 1.0.0-next.7 → 1.0.0-next.70

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.
Files changed (203) hide show
  1. package/lib/scan.d.ts +52 -0
  2. package/lib/scan.js +162 -0
  3. package/lib/scan.js.map +1 -0
  4. package/lib/scan.m.js +152 -0
  5. package/lib/scan.m.js.map +1 -0
  6. package/lib/shipload.d.ts +5696 -1503
  7. package/lib/shipload.js +20294 -5200
  8. package/lib/shipload.js.map +1 -1
  9. package/lib/shipload.m.js +20371 -5683
  10. package/lib/shipload.m.js.map +1 -1
  11. package/lib/testing.d.ts +1648 -0
  12. package/lib/testing.js +6980 -0
  13. package/lib/testing.js.map +1 -0
  14. package/lib/testing.m.js +6974 -0
  15. package/lib/testing.m.js.map +1 -0
  16. package/package.json +20 -2
  17. package/src/capabilities/craftable.test.ts +82 -0
  18. package/src/capabilities/craftable.ts +82 -0
  19. package/src/capabilities/crafting.test.ts +80 -0
  20. package/src/capabilities/crafting.ts +50 -8
  21. package/src/capabilities/gathering.test.ts +29 -0
  22. package/src/capabilities/gathering.ts +38 -20
  23. package/src/capabilities/hauling.ts +0 -5
  24. package/src/capabilities/index.ts +0 -1
  25. package/src/capabilities/modules.ts +27 -30
  26. package/src/capabilities/movement.ts +1 -1
  27. package/src/capabilities/storage.test.ts +26 -0
  28. package/src/capabilities/storage.ts +22 -7
  29. package/src/contracts/fund.ts +306 -0
  30. package/src/contracts/platform.ts +312 -3
  31. package/src/contracts/server.ts +2661 -466
  32. package/src/contracts/token.ts +170 -0
  33. package/src/contracts.ts +2 -0
  34. package/src/coordinates/address.ts +88 -0
  35. package/src/coordinates/constants.test.ts +15 -0
  36. package/src/coordinates/constants.ts +23 -0
  37. package/src/coordinates/index.ts +15 -0
  38. package/src/coordinates/memo.test.ts +47 -0
  39. package/src/coordinates/memo.ts +20 -0
  40. package/src/coordinates/permutation.ts +77 -0
  41. package/src/coordinates/regions.ts +48 -0
  42. package/src/coordinates/sectors.ts +115 -0
  43. package/src/data/capabilities.ts +49 -18
  44. package/src/data/capability-formulas.ts +26 -14
  45. package/src/data/catalog.test.ts +19 -0
  46. package/src/data/catalog.ts +13 -3
  47. package/src/data/charters.json +396 -0
  48. package/src/data/citizenry-adjectives.json +42 -0
  49. package/src/data/citizenry-nouns.json +50 -0
  50. package/src/data/colors.ts +14 -32
  51. package/src/data/entities.json +421 -15
  52. package/src/data/item-ids.ts +53 -13
  53. package/src/data/items.json +330 -77
  54. package/src/data/kind-registry.json +212 -0
  55. package/src/data/kind-registry.ts +192 -0
  56. package/src/data/metadata.ts +312 -51
  57. package/src/data/recipes-runtime.ts +6 -23
  58. package/src/data/recipes.json +2173 -173
  59. package/src/data/tiers.ts +9 -6
  60. package/src/derivation/build-methods.test.ts +71 -0
  61. package/src/derivation/build-methods.ts +50 -0
  62. package/src/derivation/capabilities.test.ts +348 -0
  63. package/src/derivation/capabilities.ts +707 -0
  64. package/src/derivation/capability-mappings.ts +60 -16
  65. package/src/derivation/crafting.test.ts +17 -0
  66. package/src/derivation/crafting.ts +57 -34
  67. package/src/derivation/index.ts +27 -2
  68. package/src/derivation/recipe-usage.test.ts +88 -0
  69. package/src/derivation/recipe-usage.ts +141 -0
  70. package/src/derivation/reserve-regen.ts +34 -0
  71. package/src/derivation/resources.ts +9 -1
  72. package/src/derivation/rollups.test.ts +55 -0
  73. package/src/derivation/rollups.ts +72 -0
  74. package/src/derivation/stars.test.ts +67 -0
  75. package/src/derivation/stars.ts +25 -0
  76. package/src/derivation/stat-scaling.ts +12 -0
  77. package/src/derivation/stats.ts +6 -6
  78. package/src/derivation/strata.ts +5 -3
  79. package/src/derivation/stratum.ts +26 -22
  80. package/src/derivation/tiers.ts +40 -7
  81. package/src/derivation/upgrades.ts +14 -0
  82. package/src/derivation/wormhole.ts +154 -0
  83. package/src/entities/entity.ts +102 -0
  84. package/src/entities/gamestate.ts +3 -28
  85. package/src/entities/makers.ts +144 -136
  86. package/src/entities/slot-multiplier.ts +43 -0
  87. package/src/entity/hydrate.test.ts +156 -0
  88. package/src/entity/hydrate.ts +86 -0
  89. package/src/errors.ts +21 -16
  90. package/src/format.ts +42 -9
  91. package/src/index-module.ts +411 -32
  92. package/src/influence/ballot.test.ts +103 -0
  93. package/src/influence/ballot.ts +98 -0
  94. package/src/influence/categories.ts +32 -0
  95. package/src/influence/charter-metadata.test.ts +136 -0
  96. package/src/influence/charter-metadata.ts +236 -0
  97. package/src/influence/charters.test.ts +112 -0
  98. package/src/influence/charters.ts +150 -0
  99. package/src/influence/citizenry.ts +38 -0
  100. package/src/influence/civic-shuttle.test.ts +202 -0
  101. package/src/influence/civic-shuttle.ts +142 -0
  102. package/src/influence/civic.test.ts +287 -0
  103. package/src/influence/civic.ts +269 -0
  104. package/src/influence/constants.ts +84 -0
  105. package/src/influence/decay.ts +20 -0
  106. package/src/influence/decomp.ts +95 -0
  107. package/src/influence/demand.ts +106 -0
  108. package/src/influence/duration.ts +101 -0
  109. package/src/influence/index.ts +118 -0
  110. package/src/influence/influence.test.ts +450 -0
  111. package/src/influence/quality.ts +25 -0
  112. package/src/influence/valuation.ts +154 -0
  113. package/src/managers/actions.ts +940 -93
  114. package/src/managers/balances.test.ts +161 -0
  115. package/src/managers/balances.ts +93 -0
  116. package/src/managers/base.ts +10 -2
  117. package/src/managers/cluster.test.ts +39 -0
  118. package/src/managers/cluster.ts +53 -0
  119. package/src/managers/construction-types.ts +80 -0
  120. package/src/managers/construction.ts +453 -0
  121. package/src/managers/context.ts +58 -1
  122. package/src/managers/coordinates.ts +14 -0
  123. package/src/managers/entities.ts +35 -63
  124. package/src/managers/epochs.ts +72 -1
  125. package/src/managers/index.ts +38 -1
  126. package/src/managers/influence.test.ts +56 -0
  127. package/src/managers/influence.ts +425 -0
  128. package/src/managers/jobs.test.ts +80 -0
  129. package/src/managers/jobs.ts +54 -0
  130. package/src/managers/locations.ts +25 -29
  131. package/src/managers/nft.test.ts +21 -0
  132. package/src/managers/nft.ts +110 -0
  133. package/src/managers/players.ts +25 -0
  134. package/src/managers/plot.ts +122 -0
  135. package/src/nft/atomicassets.abi.json +1342 -0
  136. package/src/nft/atomicassets.ts +237 -0
  137. package/src/nft/atomicdata.ts +130 -0
  138. package/src/nft/buildImmutableData.ts +388 -0
  139. package/src/nft/description.ts +222 -100
  140. package/src/nft/index.ts +3 -0
  141. package/src/planner/index.ts +296 -0
  142. package/src/planner/planner.test.ts +459 -0
  143. package/src/receipts.ts +22 -0
  144. package/src/resolution/describe-module.ts +43 -25
  145. package/src/resolution/display-name.ts +39 -11
  146. package/src/resolution/resolve-item.test.ts +48 -0
  147. package/src/resolution/resolve-item.ts +112 -58
  148. package/src/scan/index.ts +244 -0
  149. package/src/scan/scan-wasm.base64.ts +2 -0
  150. package/src/scheduling/accessor.ts +65 -23
  151. package/src/scheduling/availability.test.ts +320 -0
  152. package/src/scheduling/availability.ts +251 -0
  153. package/src/scheduling/cancel.test.ts +550 -0
  154. package/src/scheduling/cancel.ts +254 -0
  155. package/src/scheduling/cluster-stock.test.ts +31 -0
  156. package/src/scheduling/cluster-stock.ts +15 -0
  157. package/src/scheduling/energy.ts +47 -0
  158. package/src/scheduling/epoch.ts +1 -0
  159. package/src/scheduling/idle-resolve.ts +45 -0
  160. package/src/scheduling/jobs.test.ts +49 -0
  161. package/src/scheduling/jobs.ts +137 -0
  162. package/src/scheduling/lane-core.ts +128 -0
  163. package/src/scheduling/lanes.test.ts +250 -0
  164. package/src/scheduling/lanes.ts +227 -0
  165. package/src/scheduling/projection.ts +265 -149
  166. package/src/scheduling/schedule.test.ts +163 -0
  167. package/src/scheduling/schedule.ts +282 -116
  168. package/src/scheduling/task-cargo.test.ts +110 -0
  169. package/src/scheduling/task-cargo.ts +62 -0
  170. package/src/scheduling/task-effects.test.ts +35 -0
  171. package/src/scheduling/task-effects.ts +34 -0
  172. package/src/scheduling/unwrap.test.ts +86 -0
  173. package/src/scheduling/unwrap.ts +190 -0
  174. package/src/shipload.ts +63 -4
  175. package/src/subscriptions/manager.cluster.test.ts +51 -0
  176. package/src/subscriptions/manager.ts +245 -172
  177. package/src/subscriptions/mappers.ts +5 -8
  178. package/src/subscriptions/types.ts +28 -3
  179. package/src/testing/catalog-hash.ts +19 -0
  180. package/src/testing/index.ts +2 -0
  181. package/src/testing/projection-parity.ts +167 -0
  182. package/src/travel/reach.test.ts +86 -0
  183. package/src/travel/reach.ts +34 -0
  184. package/src/travel/route-build.test.ts +63 -0
  185. package/src/travel/route-build.ts +60 -0
  186. package/src/travel/route-planner.ts +288 -0
  187. package/src/travel/route-simulator.ts +170 -0
  188. package/src/travel/travel.ts +190 -123
  189. package/src/types/capabilities.ts +37 -8
  190. package/src/types/entity.ts +3 -3
  191. package/src/types/index.ts +0 -1
  192. package/src/types.ts +81 -23
  193. package/src/utils/cargo.test.ts +14 -0
  194. package/src/utils/cargo.ts +2 -0
  195. package/src/utils/display-name.ts +70 -0
  196. package/src/utils/hash.ts +22 -2
  197. package/src/utils/system.ts +24 -5
  198. package/src/capabilities/loading.ts +0 -8
  199. package/src/entities/container.ts +0 -123
  200. package/src/entities/ship-deploy.ts +0 -295
  201. package/src/entities/ship.ts +0 -221
  202. package/src/entities/warehouse.ts +0 -127
  203. package/src/types/entity-traits.ts +0 -69
@@ -0,0 +1,459 @@
1
+ import {UInt8, UInt16, UInt32, UInt64} from '@wharfkit/antelope'
2
+ import {expect, test, describe} from 'bun:test'
3
+ import {ServerContract} from '../contracts'
4
+ import {encodeStats} from '../derivation/crafting'
5
+ import {ITEM_GATHERER_T1} from '../data/item-ids'
6
+ import {
7
+ planParallelTransfer,
8
+ planSingleGather,
9
+ type GatherPlanEntity,
10
+ gatherEnergyCost,
11
+ splitCost,
12
+ maxQtyForCharge,
13
+ buildGatherPlan,
14
+ } from './index'
15
+
16
+ type ModuleEntry = ServerContract.Types.module_entry
17
+ type Lane = ServerContract.Types.lane
18
+
19
+ function makeModuleEntry(itemId: number, stats: bigint): ModuleEntry {
20
+ return {
21
+ type: UInt8.from(0),
22
+ installed: {
23
+ item_id: UInt16.from(itemId),
24
+ stats: UInt64.from(stats),
25
+ },
26
+ } as unknown as ModuleEntry
27
+ }
28
+
29
+ function gathererLane(
30
+ slotIndex: number,
31
+ yieldVal: number,
32
+ drain: number,
33
+ depth: number
34
+ ): ServerContract.Types.gatherer_lane {
35
+ return ServerContract.Types.gatherer_lane.from({
36
+ slot_index: UInt8.from(slotIndex),
37
+ yield: UInt16.from(yieldVal),
38
+ drain: UInt32.from(drain),
39
+ depth: UInt16.from(depth),
40
+ output_pct: UInt16.from(100),
41
+ })
42
+ }
43
+
44
+ function loaderLane(
45
+ slotIndex: number,
46
+ mass: number,
47
+ thrust: number
48
+ ): ServerContract.Types.loader_lane {
49
+ return ServerContract.Types.loader_lane.from({
50
+ slot_index: UInt8.from(slotIndex),
51
+ mass: UInt32.from(mass),
52
+ thrust: UInt16.from(thrust),
53
+ output_pct: UInt16.from(100),
54
+ })
55
+ }
56
+
57
+ function energyStats(capacity: number, recharge: number): ServerContract.Types.energy_stats {
58
+ return ServerContract.Types.energy_stats.from({
59
+ capacity: UInt32.from(capacity),
60
+ recharge: UInt32.from(recharge),
61
+ })
62
+ }
63
+
64
+ interface EntityOverrides {
65
+ gatherer_lanes?: ServerContract.Types.gatherer_lane[]
66
+ loader_lanes?: ServerContract.Types.loader_lane[]
67
+ generator?: ServerContract.Types.energy_stats
68
+ energy?: number
69
+ lanes?: ServerContract.Types.lane[]
70
+ }
71
+
72
+ function entity(overrides: EntityOverrides = {}): GatherPlanEntity {
73
+ return {
74
+ gatherer_lanes: overrides.gatherer_lanes ?? [],
75
+ loader_lanes: overrides.loader_lanes ?? [],
76
+ generator: overrides.generator,
77
+ energy: overrides.energy !== undefined ? UInt32.from(overrides.energy) : undefined,
78
+ lanes: overrides.lanes ?? [],
79
+ coordinates: ServerContract.Types.coordinates.from({x: 0, y: 0}),
80
+ cargo: [],
81
+ cargomass: UInt32.from(0),
82
+ }
83
+ }
84
+
85
+ const NOW = new Date('2026-06-21T00:00:00.000Z')
86
+
87
+ describe('planParallelTransfer', () => {
88
+ test('two loader lanes: quantities proportional to thrust, sums to target', () => {
89
+ const THRUST1 = 100
90
+ const THRUST2 = 200
91
+ const QUANTITY = 90
92
+
93
+ const e = entity({
94
+ loader_lanes: [loaderLane(0, 500, THRUST1), loaderLane(1, 500, THRUST2)],
95
+ })
96
+
97
+ const plan = planParallelTransfer(e, {quantity: QUANTITY})
98
+
99
+ expect(plan).toHaveLength(2)
100
+ expect(plan.reduce((s, p) => s + p.quantity, 0)).toBe(QUANTITY)
101
+
102
+ const q1 = plan.find((p) => p.slot === 0)!.quantity
103
+ const q2 = plan.find((p) => p.slot === 1)!.quantity
104
+ expect(q2 / q1).toBeCloseTo(THRUST2 / THRUST1, 0)
105
+ })
106
+
107
+ test('no loader lanes: returns empty plan', () => {
108
+ const plan = planParallelTransfer(entity({loader_lanes: []}), {quantity: 10})
109
+ expect(plan).toHaveLength(0)
110
+ })
111
+
112
+ test('thrust=0 loader lane (no-loader/mobility case): returns empty plan', () => {
113
+ const plan = planParallelTransfer(entity({loader_lanes: [loaderLane(0, 500, 0)]}), {
114
+ quantity: 10,
115
+ })
116
+ expect(plan).toHaveLength(0)
117
+ })
118
+
119
+ test("'max' target: each loader lane gets >= 1 unit", () => {
120
+ const e = entity({
121
+ loader_lanes: [loaderLane(0, 500, 100), loaderLane(1, 500, 200)],
122
+ })
123
+ const plan = planParallelTransfer(e, 'max')
124
+ expect(plan.length).toBeGreaterThan(0)
125
+ for (const entry of plan) {
126
+ expect(entry.quantity).toBeGreaterThanOrEqual(1)
127
+ }
128
+ })
129
+ })
130
+
131
+ describe('cost helpers', () => {
132
+ const STRATUM = 100
133
+ const ITEM_MASS = 10
134
+ const RICHNESS = 500
135
+
136
+ test('gatherEnergyCost is 0 for non-positive quantity and positive otherwise', () => {
137
+ const lane = gathererLane(0, 700, 1_250_000, 5000)
138
+ expect(gatherEnergyCost(lane, 0, STRATUM, ITEM_MASS, RICHNESS)).toBe(0)
139
+ expect(gatherEnergyCost(lane, 100, STRATUM, ITEM_MASS, RICHNESS)).toBeGreaterThan(0)
140
+ })
141
+
142
+ test('splitCost across two lanes is monotonic non-decreasing in quantity', () => {
143
+ const reaching = [
144
+ gathererLane(0, 700, 1_250_000, 5000),
145
+ gathererLane(1, 700, 1_250_000, 5000),
146
+ ]
147
+ const c100 = splitCost(reaching, 100, STRATUM, ITEM_MASS, RICHNESS)
148
+ const c200 = splitCost(reaching, 200, STRATUM, ITEM_MASS, RICHNESS)
149
+ expect(c200).toBeGreaterThanOrEqual(c100)
150
+ })
151
+
152
+ test('maxQtyForCharge returns hi when the whole batch fits the charge', () => {
153
+ const reaching = [gathererLane(0, 700, 1_250_000, 5000)]
154
+ // capacity huge → the full 500 fits
155
+ expect(maxQtyForCharge(reaching, 500, 1_000_000, STRATUM, ITEM_MASS, RICHNESS)).toBe(500)
156
+ })
157
+
158
+ test('maxQtyForCharge finds the boundary: result fits, result+1 does not', () => {
159
+ const reaching = [gathererLane(0, 700, 1_250_000, 5000)]
160
+ const capacity = 40_000
161
+ const q = maxQtyForCharge(reaching, 100_000, capacity, STRATUM, ITEM_MASS, RICHNESS)
162
+ expect(splitCost(reaching, q, STRATUM, ITEM_MASS, RICHNESS)).toBeLessThanOrEqual(capacity)
163
+ expect(splitCost(reaching, q + 1, STRATUM, ITEM_MASS, RICHNESS)).toBeGreaterThan(capacity)
164
+ })
165
+ })
166
+
167
+ describe('buildGatherPlan', () => {
168
+ const STRATUM = 100
169
+ const OPTS = {
170
+ richness: 500,
171
+ itemMass: 10,
172
+ holdRoom: 1_000_000,
173
+ reserveRemaining: 1_000_000,
174
+ now: NOW,
175
+ }
176
+
177
+ test('single cycle when the target fits one charge and energy is full', () => {
178
+ const e = entity({
179
+ gatherer_lanes: [gathererLane(0, 700, 1_250_000, 5000)],
180
+ generator: energyStats(1_200_000, 2000),
181
+ energy: 1_200_000,
182
+ })
183
+ const plan = buildGatherPlan(e, STRATUM, {quantity: 100}, OPTS)
184
+ expect(plan.cycleCount).toBe(1)
185
+ expect(plan.totalOre).toBe(100)
186
+ expect(plan.cycles[0].rechargeBefore).toBe(false)
187
+ expect(plan.cycles[0].limpets).toHaveLength(1)
188
+ expect(plan.cycles[0].limpets[0].quantity).toBe(100)
189
+ expect(plan.cap).toBe('requested')
190
+ })
191
+
192
+ test('two limpets split one charge proportional to yield, finish within 1s of each other', () => {
193
+ const e = entity({
194
+ gatherer_lanes: [
195
+ gathererLane(0, 200, 500_000, 5000),
196
+ gathererLane(1, 400, 500_000, 5000),
197
+ ],
198
+ generator: energyStats(60_000_000, 2000),
199
+ energy: 60_000_000,
200
+ })
201
+ const plan = buildGatherPlan(e, STRATUM, {quantity: 60}, OPTS)
202
+ expect(plan.cycleCount).toBe(1)
203
+ const [a, b] = plan.cycles[0].limpets
204
+ expect(a.quantity + b.quantity).toBe(60)
205
+ expect(b.quantity).toBeGreaterThan(a.quantity) // higher yield gets more
206
+ expect(Math.abs(a.durationSeconds - b.durationSeconds)).toBeLessThanOrEqual(2)
207
+ })
208
+
209
+ test('multi-cycle: fills beyond one charge; totalOre equals fill target; each cycle fits the charge', () => {
210
+ const CAP = 40_000
211
+ const e = entity({
212
+ gatherer_lanes: [gathererLane(0, 700, 1_250_000, 5000)],
213
+ generator: energyStats(CAP, 2),
214
+ energy: CAP,
215
+ })
216
+ const plan = buildGatherPlan(e, STRATUM, {quantity: 500}, OPTS)
217
+ expect(plan.cycleCount).toBeGreaterThan(1)
218
+ expect(plan.totalOre).toBe(500)
219
+ // every cycle's summed gather cost fits one full charge
220
+ for (const c of plan.cycles) {
221
+ const cost = splitCost(
222
+ e.gatherer_lanes,
223
+ c.batchOre,
224
+ STRATUM,
225
+ OPTS.itemMass,
226
+ OPTS.richness
227
+ )
228
+ expect(cost).toBeLessThanOrEqual(CAP)
229
+ }
230
+ // cycles after the first recharge to full
231
+ expect(plan.cycles.slice(1).every((c) => c.rechargeBefore)).toBe(true)
232
+ // limpet quantities across all cycles sum to the target
233
+ const gathered = plan.cycles.flatMap((c) => c.limpets).reduce((s, l) => s + l.quantity, 0)
234
+ expect(gathered).toBe(500)
235
+ })
236
+
237
+ test('cap reasons: hold binds, reserve binds, requested binds', () => {
238
+ const e = entity({
239
+ gatherer_lanes: [gathererLane(0, 700, 1_250_000, 5000)],
240
+ generator: energyStats(60_000_000, 2000),
241
+ energy: 60_000_000,
242
+ })
243
+ expect(
244
+ buildGatherPlan(e, STRATUM, 'max', {...OPTS, holdRoom: 300, reserveRemaining: 9999}).cap
245
+ ).toBe('hold')
246
+ expect(
247
+ buildGatherPlan(e, STRATUM, 'max', {...OPTS, holdRoom: 9999, reserveRemaining: 200}).cap
248
+ ).toBe('reserve')
249
+ expect(buildGatherPlan(e, STRATUM, {quantity: 50}, OPTS).cap).toBe('requested')
250
+ })
251
+
252
+ test('total ETA sums per-cycle recharge + gather seconds', () => {
253
+ const CAP = 40_000
254
+ const e = entity({
255
+ gatherer_lanes: [gathererLane(0, 700, 1_250_000, 5000)],
256
+ generator: energyStats(CAP, 2),
257
+ energy: CAP,
258
+ })
259
+ const plan = buildGatherPlan(e, STRATUM, {quantity: 500}, OPTS)
260
+ const expected = plan.cycles.reduce((s, c) => s + c.rechargeSeconds + c.gatherSeconds, 0)
261
+ expect(plan.totalSeconds).toBe(expected)
262
+ })
263
+
264
+ test('excludes limpets that cannot reach the stratum and warns', () => {
265
+ const e = entity({
266
+ gatherer_lanes: [
267
+ gathererLane(0, 700, 1_250_000, 5000), // reaches 100
268
+ gathererLane(1, 700, 1_250_000, 50), // does not reach 100
269
+ ],
270
+ generator: energyStats(60_000_000, 2000),
271
+ energy: 60_000_000,
272
+ })
273
+ const plan = buildGatherPlan(e, STRATUM, {quantity: 100}, OPTS)
274
+ expect(plan.cycles[0].limpets).toHaveLength(1)
275
+ expect(plan.cycles[0].limpets[0].slot).toBe(0)
276
+ expect(plan.warnings.some((w) => w.includes("can't reach"))).toBe(true)
277
+ })
278
+
279
+ test('reports structured limpet reach counts (all reaching)', () => {
280
+ const e = entity({
281
+ gatherer_lanes: [
282
+ gathererLane(0, 700, 1_250_000, 5000),
283
+ gathererLane(1, 700, 1_250_000, 5000),
284
+ ],
285
+ generator: energyStats(60_000_000, 2000),
286
+ energy: 60_000_000,
287
+ })
288
+ const plan = buildGatherPlan(e, STRATUM, {quantity: 50}, OPTS)
289
+ expect(plan.totalLimpets).toBe(2)
290
+ expect(plan.reachingCount).toBe(2)
291
+ })
292
+
293
+ test('reachingCount excludes limpets that cannot reach the stratum', () => {
294
+ const e = entity({
295
+ gatherer_lanes: [
296
+ gathererLane(0, 700, 1_250_000, 5000), // reaches 100
297
+ gathererLane(1, 700, 1_250_000, 50), // does not reach 100
298
+ ],
299
+ generator: energyStats(60_000_000, 2000),
300
+ energy: 60_000_000,
301
+ })
302
+ const plan = buildGatherPlan(e, STRATUM, {quantity: 50}, OPTS)
303
+ expect(plan.totalLimpets).toBe(2)
304
+ expect(plan.reachingCount).toBe(1)
305
+ })
306
+
307
+ test('single-limpet ship still fills across cycles', () => {
308
+ const CAP = 40_000
309
+ const e = entity({
310
+ gatherer_lanes: [gathererLane(0, 700, 1_250_000, 5000)],
311
+ generator: energyStats(CAP, 2),
312
+ energy: CAP,
313
+ })
314
+ const plan = buildGatherPlan(e, STRATUM, {quantity: 300}, OPTS)
315
+ expect(plan.cycles.every((c) => c.limpets.length === 1)).toBe(true)
316
+ expect(plan.totalOre).toBe(300)
317
+ })
318
+
319
+ test('throws when no gatherer reaches the stratum', () => {
320
+ const e = entity({
321
+ gatherer_lanes: [gathererLane(0, 700, 1_250_000, 50)],
322
+ generator: energyStats(1_200_000, 2000),
323
+ energy: 1_200_000,
324
+ })
325
+ expect(() => buildGatherPlan(e, STRATUM, 'max', OPTS)).toThrow('no gatherer reaches')
326
+ })
327
+
328
+ test('zero fill target yields an empty plan (no cycles)', () => {
329
+ const e = entity({
330
+ gatherer_lanes: [gathererLane(0, 700, 1_250_000, 5000)],
331
+ generator: energyStats(1_200_000, 2000),
332
+ energy: 1_200_000,
333
+ })
334
+ const plan = buildGatherPlan(e, STRATUM, 'max', {...OPTS, holdRoom: 0})
335
+ expect(plan.cycleCount).toBe(0)
336
+ expect(plan.totalOre).toBe(0)
337
+ expect(plan.cap).toBe('hold')
338
+ })
339
+
340
+ test('first cycle skips recharge when current energy already covers the small batch', () => {
341
+ const e = entity({
342
+ gatherer_lanes: [gathererLane(0, 700, 1_250_000, 5000)],
343
+ generator: energyStats(60_000_000, 2000),
344
+ energy: 60_000_000, // full
345
+ })
346
+ const plan = buildGatherPlan(e, STRATUM, {quantity: 50}, OPTS)
347
+ expect(plan.cycles[0].rechargeBefore).toBe(false)
348
+ expect(plan.cycles[0].rechargeSeconds).toBe(0)
349
+ })
350
+ })
351
+
352
+ describe('planSingleGather', () => {
353
+ const STRATUM = 100
354
+ const ITEM_MASS = 1000
355
+ const RICHNESS = 500
356
+ const deepStats = encodeStats([100, 900, 100]) // tol=900 => depth=5000 for tier1
357
+
358
+ test('mirrors the bot hand-assembly: laneKey, quantity, energyCost match selectGatherLane + maxQtyForCharge + splitCost', () => {
359
+ const modules: ModuleEntry[] = [makeModuleEntry(ITEM_GATHERER_T1, deepStats)]
360
+ const lane = gathererLane(0, 700, 1_250_000, 5000)
361
+
362
+ const result = planSingleGather({
363
+ modules,
364
+ entityItemId: ITEM_GATHERER_T1,
365
+ lanes: [],
366
+ gathererLanes: [lane],
367
+ stratum: STRATUM,
368
+ desiredQuantity: 100,
369
+ itemMass: ITEM_MASS,
370
+ richness: RICHNESS,
371
+ generatorCapacity: 1_000_000,
372
+ currentEnergy: 1_000_000,
373
+ })
374
+
375
+ const expectedLaneKey = 1
376
+ const expectedQuantity = maxQtyForCharge(
377
+ [lane],
378
+ 100,
379
+ 1_000_000,
380
+ STRATUM,
381
+ ITEM_MASS,
382
+ RICHNESS
383
+ )
384
+ const expectedCost = splitCost([lane], expectedQuantity, STRATUM, ITEM_MASS, RICHNESS)
385
+
386
+ expect(result.laneKey).toBe(expectedLaneKey)
387
+ expect(result.quantity).toBe(expectedQuantity)
388
+ expect(result.energyCost).toBe(expectedCost)
389
+ expect(result.needsRecharge).toBe(false)
390
+ })
391
+
392
+ test('needsRecharge is true when current energy is below the priced cost', () => {
393
+ const modules: ModuleEntry[] = [makeModuleEntry(ITEM_GATHERER_T1, deepStats)]
394
+ const lane = gathererLane(0, 700, 1_250_000, 5000)
395
+
396
+ const result = planSingleGather({
397
+ modules,
398
+ entityItemId: ITEM_GATHERER_T1,
399
+ lanes: [],
400
+ gathererLanes: [lane],
401
+ stratum: STRATUM,
402
+ desiredQuantity: 100,
403
+ itemMass: ITEM_MASS,
404
+ richness: RICHNESS,
405
+ generatorCapacity: 1_000_000,
406
+ currentEnergy: 0,
407
+ })
408
+
409
+ expect(result.energyCost).toBeGreaterThan(0)
410
+ expect(result.needsRecharge).toBe(true)
411
+ })
412
+
413
+ test('caps quantity to what fits one full charge, same boundary as maxQtyForCharge', () => {
414
+ const modules: ModuleEntry[] = [makeModuleEntry(ITEM_GATHERER_T1, deepStats)]
415
+ const lane = gathererLane(0, 700, 1_250_000, 5000)
416
+ const capacity = 40_000
417
+
418
+ const result = planSingleGather({
419
+ modules,
420
+ entityItemId: ITEM_GATHERER_T1,
421
+ lanes: [],
422
+ gathererLanes: [lane],
423
+ stratum: STRATUM,
424
+ desiredQuantity: 100_000,
425
+ itemMass: ITEM_MASS,
426
+ richness: RICHNESS,
427
+ generatorCapacity: capacity,
428
+ currentEnergy: capacity,
429
+ })
430
+
431
+ expect(
432
+ splitCost([lane], result.quantity, STRATUM, ITEM_MASS, RICHNESS)
433
+ ).toBeLessThanOrEqual(capacity)
434
+ expect(
435
+ splitCost([lane], result.quantity + 1, STRATUM, ITEM_MASS, RICHNESS)
436
+ ).toBeGreaterThan(capacity)
437
+ })
438
+
439
+ test('throws when no gatherer lane reaches the stratum, same as selectGatherLane', () => {
440
+ const shallowStats = encodeStats([100, 10, 100]) // depth=550 for tier1
441
+ const modules: ModuleEntry[] = [makeModuleEntry(ITEM_GATHERER_T1, shallowStats)]
442
+ const lane = gathererLane(0, 700, 1_250_000, 550)
443
+
444
+ expect(() =>
445
+ planSingleGather({
446
+ modules,
447
+ entityItemId: ITEM_GATHERER_T1,
448
+ lanes: [] as Lane[],
449
+ gathererLanes: [lane],
450
+ stratum: 5000,
451
+ desiredQuantity: 100,
452
+ itemMass: ITEM_MASS,
453
+ richness: RICHNESS,
454
+ generatorCapacity: 1_000_000,
455
+ currentEnergy: 1_000_000,
456
+ })
457
+ ).toThrow('no gatherer reaches this stratum')
458
+ })
459
+ })
@@ -0,0 +1,22 @@
1
+ import {Serializer} from '@wharfkit/antelope'
2
+ import {ServerContract} from './contracts'
3
+
4
+ export type BookingReceipt = ServerContract.Types.booking_receipt
5
+
6
+ export function decodeBookingReceipt(data: unknown): BookingReceipt {
7
+ if (data === null || data === undefined) {
8
+ throw new Error('decodeBookingReceipt: no return value')
9
+ }
10
+ if (data instanceof Uint8Array || typeof data === 'string') {
11
+ return Serializer.decode({data, type: ServerContract.Types.booking_receipt})
12
+ }
13
+ return ServerContract.Types.booking_receipt.from(data as ServerContract.Types.booking_receipt)
14
+ }
15
+
16
+ export function bookingReceiptLandsAtMs(receipt: BookingReceipt): number {
17
+ return receipt.lands_at.toMilliseconds()
18
+ }
19
+
20
+ export function bookingReceiptRuntimeSeconds(receipt: BookingReceipt): number {
21
+ return Number(receipt.runtime)
22
+ }
@@ -20,6 +20,7 @@ export interface ModuleDescription {
20
20
  export interface RenderDescriptionOptions {
21
21
  translate?: (id: string, fallback: string) => string
22
22
  formatNumber?: (n: number) => string
23
+ formatParam?: (paramName: string, value: number, descId: string) => string | undefined
23
24
  }
24
25
 
25
26
  interface TemplateSpec {
@@ -27,6 +28,7 @@ interface TemplateSpec {
27
28
  template: string
28
29
  params: readonly [string, string][]
29
30
  highlightKeys: readonly string[]
31
+ derive?: Record<string, (get: (label: string) => number) => number>
30
32
  }
31
33
 
32
34
  const TEMPLATES: Record<string, TemplateSpec> = {
@@ -53,43 +55,48 @@ const TEMPLATES: Record<string, TemplateSpec> = {
53
55
  gatherer: {
54
56
  id: 'module.gatherer.description',
55
57
  template:
56
- 'mines resources at {yield} speed to a max depth of {depth} with {speed} gather speed while draining {drain} energy per second',
58
+ 'mines resources at {yield} yield to a max depth of {depth} while draining {drain} energy per minute',
57
59
  params: [
58
60
  ['yield', 'Yield'],
59
- ['drain', 'Drain'],
60
61
  ['depth', 'Depth'],
61
- ['speed', 'Speed'],
62
62
  ],
63
- highlightKeys: ['yield', 'depth', 'speed', 'drain'],
63
+ highlightKeys: ['yield', 'depth', 'drain'],
64
+ derive: {
65
+ drain: (get) => Math.round((get('Drain') / 10000) * 60 * 10) / 10,
66
+ },
64
67
  },
65
- loader: {
66
- id: 'module.loader.description',
67
- template:
68
- '{quantity} loader that generates {thrust} thrust with a weight of {mass} per unit',
68
+ loading: {
69
+ id: 'module.loading.description',
70
+ template: 'generates {thrust} thrust with a weight of {mass} per unit',
69
71
  params: [
70
- ['quantity', 'Quantity'],
71
72
  ['thrust', 'Thrust'],
72
73
  ['mass', 'Mass'],
73
74
  ],
74
- highlightKeys: ['quantity', 'thrust', 'mass'],
75
+ highlightKeys: ['thrust', 'mass'],
75
76
  },
76
- crafter: {
77
- id: 'module.crafter.description',
78
- template: 'manufactures items at {speed} speed while draining {drain} energy per second',
79
- params: [
80
- ['speed', 'Speed'],
81
- ['drain', 'Drain'],
82
- ],
77
+ crafting: {
78
+ id: 'module.crafting.description',
79
+ template: 'manufactures items at {speed} speed while draining {drain} energy per minute',
80
+ params: [['speed', 'Speed']],
83
81
  highlightKeys: ['speed', 'drain'],
82
+ derive: {
83
+ drain: (get) => Math.round(((get('Drain') * get('Speed')) / 150000) * 60 * 10) / 10,
84
+ },
84
85
  },
85
86
  storage: {
86
87
  id: 'module.storage.description',
87
- template: 'boosts cargo capacity by {bonus}%',
88
- params: [['bonus', 'Capacity Bonus']],
89
- highlightKeys: ['bonus'],
88
+ template: 'adds {capacity} cargo capacity',
89
+ params: [['capacity', 'Cargo Capacity']],
90
+ highlightKeys: ['capacity'],
91
+ },
92
+ energy: {
93
+ id: 'module.energy-capacity.description',
94
+ template: 'adds {capacity} energy capacity',
95
+ params: [['capacity', 'Energy Capacity']],
96
+ highlightKeys: ['capacity'],
90
97
  },
91
- hauler: {
92
- id: 'module.hauler.description',
98
+ hauling: {
99
+ id: 'module.hauling.description',
93
100
  template:
94
101
  'locks onto up to {capacity} targets at {efficiency} efficiency while draining {drain} energy per distance travelled per target',
95
102
  params: [
@@ -111,6 +118,12 @@ export function describeModule(input: CapabilityInput): ModuleDescription | null
111
118
  const attr = input.attributes.find((a) => a.label === attrLabel)
112
119
  if (attr) params[paramName] = attr.value
113
120
  }
121
+ if (spec.derive) {
122
+ const get = (label: string) => input.attributes.find((a) => a.label === label)?.value ?? 0
123
+ for (const [paramName, fn] of Object.entries(spec.derive)) {
124
+ params[paramName] = fn(get)
125
+ }
126
+ }
114
127
  return {
115
128
  id: spec.id,
116
129
  template: spec.template,
@@ -127,8 +140,10 @@ export function describeModuleForItem(resolved: ResolvedItem): ModuleDescription
127
140
  }
128
141
 
129
142
  export function describeModuleForSlot(slot: ResolvedModuleSlot): ModuleDescription | null {
130
- if (!slot.installed || !slot.name || !slot.attributes) return null
131
- return describeModule({capability: slot.name, attributes: slot.attributes})
143
+ if (!slot.installed || !slot.attributes) return null
144
+ const capability = slot.capability ?? slot.name
145
+ if (!capability) return null
146
+ return describeModule({capability, attributes: slot.attributes})
132
147
  }
133
148
 
134
149
  export function renderDescription(
@@ -153,7 +168,10 @@ export function renderDescription(
153
168
  if (raw === undefined) {
154
169
  spans.push({text: `{${paramName}}`})
155
170
  } else {
156
- const formatted = typeof raw === 'number' ? formatNumber(raw) : raw
171
+ const formatted =
172
+ typeof raw === 'number'
173
+ ? (options?.formatParam?.(paramName, raw, desc.id) ?? formatNumber(raw))
174
+ : raw
157
175
  const highlight = (desc.highlightKeys as readonly string[]).includes(paramName)
158
176
  spans.push(highlight ? {text: formatted, highlight: true} : {text: formatted})
159
177
  }
@@ -1,28 +1,56 @@
1
1
  import type {ResolvedItem} from './resolve-item'
2
2
  import type {ResourceCategory} from '../types'
3
- import {CATEGORY_LABELS, TIER_ADJECTIVES} from '../types'
3
+ import {
4
+ CATEGORY_LABELS,
5
+ RESOURCE_TIER_ADJECTIVES,
6
+ COMPONENT_TIER_PREFIXES,
7
+ MODULE_TIER_PREFIXES,
8
+ } from '../types'
4
9
  import {formatMass as defaultFormatMass} from '../format'
5
10
 
6
- export interface DisplayNameInput {
7
- itemType: 'resource' | 'component' | 'module' | 'entity' | string
11
+ interface DisplayNameInputCommon {
8
12
  tier: number
9
13
  category?: ResourceCategory
10
14
  name: string
11
15
  }
12
16
 
13
- export function displayName(resolved: DisplayNameInput): string {
14
- if (resolved.itemType === 'resource') {
15
- const adj = TIER_ADJECTIVES[resolved.tier] ?? 'Unknown'
16
- const cat = resolved.category ? CATEGORY_LABELS[resolved.category] : 'Resource'
17
- return `${adj} ${cat}`
18
- }
19
- return resolved.name
17
+ export type DisplayNameInput =
18
+ | (DisplayNameInputCommon & {itemType: 'resource' | 'component' | 'module' | 'entity' | string})
19
+ | (DisplayNameInputCommon & {type: string})
20
+
21
+ function itemTypeOf(item: DisplayNameInput): string {
22
+ return 'itemType' in item ? item.itemType : item.type
23
+ }
24
+
25
+ function tierPrefix(item: DisplayNameInput): string | null {
26
+ const t = itemTypeOf(item)
27
+ if (t === 'resource') return RESOURCE_TIER_ADJECTIVES[item.tier] ?? null
28
+ if (t === 'component') return COMPONENT_TIER_PREFIXES[item.tier] ?? null
29
+ if (t === 'module') return MODULE_TIER_PREFIXES[item.tier] ?? null
30
+ return null
31
+ }
32
+
33
+ function rootName(item: DisplayNameInput): string {
34
+ if (itemTypeOf(item) !== 'resource') return item.name
35
+ return item.category ? CATEGORY_LABELS[item.category] : 'Resource'
36
+ }
37
+
38
+ // Tier-free display name: includes the resource tier adjective / component-module
39
+ // prefix, but no "(T#)" suffix. Use this when the tier is shown separately.
40
+ export function baseName(item: DisplayNameInput): string {
41
+ const prefix = tierPrefix(item)
42
+ const root = rootName(item)
43
+ return prefix ? `${prefix} ${root}` : root
44
+ }
45
+
46
+ export function displayName(item: DisplayNameInput): string {
47
+ return `${baseName(item)} (T${item.tier})`
20
48
  }
21
49
 
22
50
  export interface DescribeOptions {
23
51
  translate?: (key: string) => string
24
52
  formatNumber?: (n: number) => string
25
- formatMass?: (kg: number) => string
53
+ formatMass?: (mass: number) => string
26
54
  }
27
55
 
28
56
  export function describeItem(resolved: ResolvedItem, opts?: DescribeOptions): string {