@shipload/sdk 1.0.0-next.5 → 1.0.0-next.50

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 (144) 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 +2881 -1149
  7. package/lib/shipload.js +13031 -5511
  8. package/lib/shipload.js.map +1 -1
  9. package/lib/shipload.m.js +12807 -5460
  10. package/lib/shipload.m.js.map +1 -1
  11. package/lib/testing.d.ts +1028 -0
  12. package/lib/testing.js +4336 -0
  13. package/lib/testing.js.map +1 -0
  14. package/lib/testing.m.js +4330 -0
  15. package/lib/testing.m.js.map +1 -0
  16. package/package.json +20 -2
  17. package/src/capabilities/craftable.ts +51 -0
  18. package/src/capabilities/crafting.test.ts +7 -0
  19. package/src/capabilities/crafting.ts +6 -7
  20. package/src/capabilities/gathering.test.ts +29 -0
  21. package/src/capabilities/gathering.ts +37 -19
  22. package/src/capabilities/index.ts +0 -1
  23. package/src/capabilities/modules.ts +22 -0
  24. package/src/capabilities/storage.ts +16 -1
  25. package/src/contracts/platform.ts +231 -3
  26. package/src/contracts/server.ts +1160 -520
  27. package/src/coordinates/address.ts +88 -0
  28. package/src/coordinates/constants.test.ts +15 -0
  29. package/src/coordinates/constants.ts +23 -0
  30. package/src/coordinates/index.ts +15 -0
  31. package/src/coordinates/memo.test.ts +47 -0
  32. package/src/coordinates/memo.ts +20 -0
  33. package/src/coordinates/permutation.ts +77 -0
  34. package/src/coordinates/regions.ts +48 -0
  35. package/src/coordinates/sectors.ts +115 -0
  36. package/src/data/capabilities.ts +31 -16
  37. package/src/data/capability-formulas.ts +21 -14
  38. package/src/data/catalog.ts +0 -5
  39. package/src/data/colors.ts +13 -47
  40. package/src/data/entities.json +154 -10
  41. package/src/data/item-ids.ts +31 -12
  42. package/src/data/items.json +192 -74
  43. package/src/data/kind-registry.json +131 -0
  44. package/src/data/kind-registry.ts +172 -0
  45. package/src/data/metadata.ts +166 -48
  46. package/src/data/recipes-runtime.ts +5 -23
  47. package/src/data/recipes.json +1003 -163
  48. package/src/derivation/build-methods.test.ts +13 -0
  49. package/src/derivation/build-methods.ts +48 -0
  50. package/src/derivation/capabilities.test.ts +151 -0
  51. package/src/derivation/capabilities.ts +527 -0
  52. package/src/derivation/capability-mappings.ts +58 -16
  53. package/src/derivation/crafting.ts +36 -33
  54. package/src/derivation/index.ts +28 -2
  55. package/src/derivation/recipe-usage.test.ts +81 -0
  56. package/src/derivation/recipe-usage.ts +141 -0
  57. package/src/derivation/reserve-regen.ts +34 -0
  58. package/src/derivation/resources.ts +98 -19
  59. package/src/derivation/rollups.test.ts +55 -0
  60. package/src/derivation/rollups.ts +56 -0
  61. package/src/derivation/stars.test.ts +67 -0
  62. package/src/derivation/stars.ts +25 -0
  63. package/src/derivation/stats.ts +6 -6
  64. package/src/derivation/stratum.ts +26 -22
  65. package/src/derivation/tiers.ts +40 -7
  66. package/src/derivation/upgrades.ts +14 -0
  67. package/src/derivation/wormhole.ts +141 -0
  68. package/src/entities/entity.ts +98 -0
  69. package/src/entities/gamestate.ts +3 -28
  70. package/src/entities/makers.ts +131 -134
  71. package/src/entities/slot-multiplier.ts +43 -0
  72. package/src/errors.ts +12 -16
  73. package/src/format.ts +26 -4
  74. package/src/index-module.ts +290 -34
  75. package/src/managers/actions.ts +602 -105
  76. package/src/managers/base.ts +6 -2
  77. package/src/managers/cluster.test.ts +39 -0
  78. package/src/managers/cluster.ts +53 -0
  79. package/src/managers/construction-types.ts +80 -0
  80. package/src/managers/construction.ts +412 -0
  81. package/src/managers/context.ts +29 -1
  82. package/src/managers/coordinates.ts +14 -0
  83. package/src/managers/entities.ts +27 -66
  84. package/src/managers/epochs.ts +40 -0
  85. package/src/managers/flatten-gather-plan.test.ts +80 -0
  86. package/src/managers/index.ts +20 -1
  87. package/src/managers/locations.ts +25 -29
  88. package/src/managers/nft.test.ts +14 -0
  89. package/src/managers/nft.ts +70 -0
  90. package/src/managers/players.ts +25 -0
  91. package/src/managers/plot.ts +122 -0
  92. package/src/nft/atomicassets.abi.json +1342 -0
  93. package/src/nft/atomicassets.ts +237 -0
  94. package/src/nft/atomicdata.ts +130 -0
  95. package/src/nft/buildImmutableData.ts +336 -0
  96. package/src/nft/description.ts +104 -36
  97. package/src/nft/index.ts +3 -0
  98. package/src/planner/index.ts +242 -0
  99. package/src/planner/planner.test.ts +325 -0
  100. package/src/resolution/describe-module.ts +43 -25
  101. package/src/resolution/display-name.ts +38 -10
  102. package/src/resolution/resolve-item.test.ts +48 -0
  103. package/src/resolution/resolve-item.ts +74 -47
  104. package/src/scan/index.ts +244 -0
  105. package/src/scan/scan-wasm.base64.ts +2 -0
  106. package/src/scheduling/accessor.ts +65 -23
  107. package/src/scheduling/availability.ts +108 -0
  108. package/src/scheduling/cancel.test.ts +348 -0
  109. package/src/scheduling/cancel.ts +209 -0
  110. package/src/scheduling/energy.ts +47 -0
  111. package/src/scheduling/idle-resolve.ts +45 -0
  112. package/src/scheduling/lane-core.ts +128 -0
  113. package/src/scheduling/lanes.test.ts +249 -0
  114. package/src/scheduling/lanes.ts +198 -0
  115. package/src/scheduling/projection.ts +210 -107
  116. package/src/scheduling/schedule.ts +240 -117
  117. package/src/scheduling/task-cargo.ts +46 -0
  118. package/src/scheduling/unwrap.test.ts +86 -0
  119. package/src/scheduling/unwrap.ts +190 -0
  120. package/src/shipload.ts +26 -1
  121. package/src/subscriptions/manager.cluster.test.ts +51 -0
  122. package/src/subscriptions/manager.ts +245 -172
  123. package/src/subscriptions/mappers.ts +5 -8
  124. package/src/subscriptions/types.ts +28 -3
  125. package/src/testing/catalog-hash.ts +19 -0
  126. package/src/testing/index.ts +2 -0
  127. package/src/testing/projection-parity.ts +167 -0
  128. package/src/travel/reach.ts +23 -0
  129. package/src/travel/route-planner.ts +277 -0
  130. package/src/travel/travel.ts +148 -117
  131. package/src/types/capabilities.ts +29 -6
  132. package/src/types/entity.ts +3 -3
  133. package/src/types/index.ts +0 -1
  134. package/src/types.ts +36 -14
  135. package/src/utils/cargo.test.ts +14 -0
  136. package/src/utils/cargo.ts +29 -0
  137. package/src/utils/display-name.ts +70 -0
  138. package/src/utils/system.ts +36 -24
  139. package/src/capabilities/loading.ts +0 -8
  140. package/src/entities/container.ts +0 -123
  141. package/src/entities/ship-deploy.ts +0 -295
  142. package/src/entities/ship.ts +0 -221
  143. package/src/entities/warehouse.ts +0 -127
  144. package/src/types/entity-traits.ts +0 -69
@@ -0,0 +1,325 @@
1
+ import {UInt8, UInt16, UInt32, UInt64} from '@wharfkit/antelope'
2
+ import {expect, test, describe} from 'bun:test'
3
+ import {ServerContract} from '../contracts'
4
+ import {
5
+ planParallelTransfer,
6
+ type GatherPlanEntity,
7
+ gatherEnergyCost,
8
+ splitCost,
9
+ maxQtyForCharge,
10
+ buildGatherPlan,
11
+ } from './index'
12
+
13
+ function gathererLane(
14
+ slotIndex: number,
15
+ yieldVal: number,
16
+ drain: number,
17
+ depth: number
18
+ ): ServerContract.Types.gatherer_lane {
19
+ return ServerContract.Types.gatherer_lane.from({
20
+ slot_index: UInt8.from(slotIndex),
21
+ yield: UInt16.from(yieldVal),
22
+ drain: UInt32.from(drain),
23
+ depth: UInt16.from(depth),
24
+ output_pct: UInt16.from(100),
25
+ })
26
+ }
27
+
28
+ function loaderLane(
29
+ slotIndex: number,
30
+ mass: number,
31
+ thrust: number
32
+ ): ServerContract.Types.loader_lane {
33
+ return ServerContract.Types.loader_lane.from({
34
+ slot_index: UInt8.from(slotIndex),
35
+ mass: UInt32.from(mass),
36
+ thrust: UInt16.from(thrust),
37
+ output_pct: UInt16.from(100),
38
+ })
39
+ }
40
+
41
+ function energyStats(capacity: number, recharge: number): ServerContract.Types.energy_stats {
42
+ return ServerContract.Types.energy_stats.from({
43
+ capacity: UInt32.from(capacity),
44
+ recharge: UInt32.from(recharge),
45
+ })
46
+ }
47
+
48
+ interface EntityOverrides {
49
+ gatherer_lanes?: ServerContract.Types.gatherer_lane[]
50
+ loader_lanes?: ServerContract.Types.loader_lane[]
51
+ generator?: ServerContract.Types.energy_stats
52
+ energy?: number
53
+ lanes?: ServerContract.Types.lane[]
54
+ }
55
+
56
+ function entity(overrides: EntityOverrides = {}): GatherPlanEntity {
57
+ return {
58
+ gatherer_lanes: overrides.gatherer_lanes ?? [],
59
+ loader_lanes: overrides.loader_lanes ?? [],
60
+ generator: overrides.generator,
61
+ energy: overrides.energy !== undefined ? UInt16.from(overrides.energy) : undefined,
62
+ lanes: overrides.lanes ?? [],
63
+ coordinates: ServerContract.Types.coordinates.from({x: 0, y: 0}),
64
+ cargo: [],
65
+ cargomass: UInt32.from(0),
66
+ }
67
+ }
68
+
69
+ const NOW = new Date('2026-06-21T00:00:00.000Z')
70
+
71
+ describe('planParallelTransfer', () => {
72
+ test('two loader lanes: quantities proportional to thrust, sums to target', () => {
73
+ const THRUST1 = 100
74
+ const THRUST2 = 200
75
+ const QUANTITY = 90
76
+
77
+ const e = entity({
78
+ loader_lanes: [loaderLane(0, 500, THRUST1), loaderLane(1, 500, THRUST2)],
79
+ })
80
+
81
+ const plan = planParallelTransfer(e, {quantity: QUANTITY})
82
+
83
+ expect(plan).toHaveLength(2)
84
+ expect(plan.reduce((s, p) => s + p.quantity, 0)).toBe(QUANTITY)
85
+
86
+ const q1 = plan.find((p) => p.slot === 0)!.quantity
87
+ const q2 = plan.find((p) => p.slot === 1)!.quantity
88
+ expect(q2 / q1).toBeCloseTo(THRUST2 / THRUST1, 0)
89
+ })
90
+
91
+ test('no loader lanes: returns empty plan', () => {
92
+ const plan = planParallelTransfer(entity({loader_lanes: []}), {quantity: 10})
93
+ expect(plan).toHaveLength(0)
94
+ })
95
+
96
+ test('thrust=0 loader lane (no-loader/mobility case): returns empty plan', () => {
97
+ const plan = planParallelTransfer(entity({loader_lanes: [loaderLane(0, 500, 0)]}), {
98
+ quantity: 10,
99
+ })
100
+ expect(plan).toHaveLength(0)
101
+ })
102
+
103
+ test("'max' target: each loader lane gets >= 1 unit", () => {
104
+ const e = entity({
105
+ loader_lanes: [loaderLane(0, 500, 100), loaderLane(1, 500, 200)],
106
+ })
107
+ const plan = planParallelTransfer(e, 'max')
108
+ expect(plan.length).toBeGreaterThan(0)
109
+ for (const entry of plan) {
110
+ expect(entry.quantity).toBeGreaterThanOrEqual(1)
111
+ }
112
+ })
113
+ })
114
+
115
+ describe('cost helpers', () => {
116
+ const STRATUM = 100
117
+ const ITEM_MASS = 1000
118
+ const RICHNESS = 500
119
+
120
+ test('gatherEnergyCost is 0 for non-positive quantity and positive otherwise', () => {
121
+ const lane = gathererLane(0, 700, 1250, 5000)
122
+ expect(gatherEnergyCost(lane, 0, STRATUM, ITEM_MASS, RICHNESS)).toBe(0)
123
+ expect(gatherEnergyCost(lane, 100, STRATUM, ITEM_MASS, RICHNESS)).toBeGreaterThan(0)
124
+ })
125
+
126
+ test('splitCost across two lanes is monotonic non-decreasing in quantity', () => {
127
+ const reaching = [gathererLane(0, 700, 1250, 5000), gathererLane(1, 700, 1250, 5000)]
128
+ const c100 = splitCost(reaching, 100, STRATUM, ITEM_MASS, RICHNESS)
129
+ const c200 = splitCost(reaching, 200, STRATUM, ITEM_MASS, RICHNESS)
130
+ expect(c200).toBeGreaterThanOrEqual(c100)
131
+ })
132
+
133
+ test('maxQtyForCharge returns hi when the whole batch fits the charge', () => {
134
+ const reaching = [gathererLane(0, 700, 1250, 5000)]
135
+ // capacity huge → the full 500 fits
136
+ expect(maxQtyForCharge(reaching, 500, 1_000_000, STRATUM, ITEM_MASS, RICHNESS)).toBe(500)
137
+ })
138
+
139
+ test('maxQtyForCharge finds the boundary: result fits, result+1 does not', () => {
140
+ const reaching = [gathererLane(0, 700, 1250, 5000)]
141
+ const capacity = 40
142
+ const q = maxQtyForCharge(reaching, 100_000, capacity, STRATUM, ITEM_MASS, RICHNESS)
143
+ expect(splitCost(reaching, q, STRATUM, ITEM_MASS, RICHNESS)).toBeLessThanOrEqual(capacity)
144
+ expect(splitCost(reaching, q + 1, STRATUM, ITEM_MASS, RICHNESS)).toBeGreaterThan(capacity)
145
+ })
146
+ })
147
+
148
+ describe('buildGatherPlan', () => {
149
+ const STRATUM = 100
150
+ const OPTS = {
151
+ richness: 500,
152
+ itemMass: 1000,
153
+ holdRoom: 1_000_000,
154
+ reserveRemaining: 1_000_000,
155
+ now: NOW,
156
+ }
157
+
158
+ test('single cycle when the target fits one charge and energy is full', () => {
159
+ const e = entity({
160
+ gatherer_lanes: [gathererLane(0, 700, 1250, 5000)],
161
+ generator: energyStats(1200, 2),
162
+ energy: 1200,
163
+ })
164
+ const plan = buildGatherPlan(e, STRATUM, {quantity: 100}, OPTS)
165
+ expect(plan.cycleCount).toBe(1)
166
+ expect(plan.totalOre).toBe(100)
167
+ expect(plan.cycles[0].rechargeBefore).toBe(false)
168
+ expect(plan.cycles[0].limpets).toHaveLength(1)
169
+ expect(plan.cycles[0].limpets[0].quantity).toBe(100)
170
+ expect(plan.cap).toBe('requested')
171
+ })
172
+
173
+ test('two limpets split one charge proportional to yield, finish within 1s of each other', () => {
174
+ const e = entity({
175
+ gatherer_lanes: [gathererLane(0, 200, 500, 5000), gathererLane(1, 400, 500, 5000)],
176
+ generator: energyStats(60_000, 2),
177
+ energy: 60_000,
178
+ })
179
+ const plan = buildGatherPlan(e, STRATUM, {quantity: 60}, OPTS)
180
+ expect(plan.cycleCount).toBe(1)
181
+ const [a, b] = plan.cycles[0].limpets
182
+ expect(a.quantity + b.quantity).toBe(60)
183
+ expect(b.quantity).toBeGreaterThan(a.quantity) // higher yield gets more
184
+ expect(Math.abs(a.durationSeconds - b.durationSeconds)).toBeLessThanOrEqual(2)
185
+ })
186
+
187
+ test('multi-cycle: fills beyond one charge; totalOre equals fill target; each cycle fits the charge', () => {
188
+ const CAP = 40
189
+ const e = entity({
190
+ gatherer_lanes: [gathererLane(0, 700, 1250, 5000)],
191
+ generator: energyStats(CAP, 2),
192
+ energy: CAP,
193
+ })
194
+ const plan = buildGatherPlan(e, STRATUM, {quantity: 500}, OPTS)
195
+ expect(plan.cycleCount).toBeGreaterThan(1)
196
+ expect(plan.totalOre).toBe(500)
197
+ // every cycle's summed gather cost fits one full charge
198
+ for (const c of plan.cycles) {
199
+ const cost = splitCost(
200
+ e.gatherer_lanes,
201
+ c.batchOre,
202
+ STRATUM,
203
+ OPTS.itemMass,
204
+ OPTS.richness
205
+ )
206
+ expect(cost).toBeLessThanOrEqual(CAP)
207
+ }
208
+ // cycles after the first recharge to full
209
+ expect(plan.cycles.slice(1).every((c) => c.rechargeBefore)).toBe(true)
210
+ // limpet quantities across all cycles sum to the target
211
+ const gathered = plan.cycles.flatMap((c) => c.limpets).reduce((s, l) => s + l.quantity, 0)
212
+ expect(gathered).toBe(500)
213
+ })
214
+
215
+ test('cap reasons: hold binds, reserve binds, requested binds', () => {
216
+ const e = entity({
217
+ gatherer_lanes: [gathererLane(0, 700, 1250, 5000)],
218
+ generator: energyStats(60_000, 2),
219
+ energy: 60_000,
220
+ })
221
+ expect(
222
+ buildGatherPlan(e, STRATUM, 'max', {...OPTS, holdRoom: 300, reserveRemaining: 9999}).cap
223
+ ).toBe('hold')
224
+ expect(
225
+ buildGatherPlan(e, STRATUM, 'max', {...OPTS, holdRoom: 9999, reserveRemaining: 200}).cap
226
+ ).toBe('reserve')
227
+ expect(buildGatherPlan(e, STRATUM, {quantity: 50}, OPTS).cap).toBe('requested')
228
+ })
229
+
230
+ test('total ETA sums per-cycle recharge + gather seconds', () => {
231
+ const CAP = 40
232
+ const e = entity({
233
+ gatherer_lanes: [gathererLane(0, 700, 1250, 5000)],
234
+ generator: energyStats(CAP, 2),
235
+ energy: CAP,
236
+ })
237
+ const plan = buildGatherPlan(e, STRATUM, {quantity: 500}, OPTS)
238
+ const expected = plan.cycles.reduce((s, c) => s + c.rechargeSeconds + c.gatherSeconds, 0)
239
+ expect(plan.totalSeconds).toBe(expected)
240
+ })
241
+
242
+ test('excludes limpets that cannot reach the stratum and warns', () => {
243
+ const e = entity({
244
+ gatherer_lanes: [
245
+ gathererLane(0, 700, 1250, 5000), // reaches 100
246
+ gathererLane(1, 700, 1250, 50), // does not reach 100
247
+ ],
248
+ generator: energyStats(60_000, 2),
249
+ energy: 60_000,
250
+ })
251
+ const plan = buildGatherPlan(e, STRATUM, {quantity: 100}, OPTS)
252
+ expect(plan.cycles[0].limpets).toHaveLength(1)
253
+ expect(plan.cycles[0].limpets[0].slot).toBe(0)
254
+ expect(plan.warnings.some((w) => w.includes("can't reach"))).toBe(true)
255
+ })
256
+
257
+ test('reports structured limpet reach counts (all reaching)', () => {
258
+ const e = entity({
259
+ gatherer_lanes: [gathererLane(0, 700, 1250, 5000), gathererLane(1, 700, 1250, 5000)],
260
+ generator: energyStats(60_000, 2),
261
+ energy: 60_000,
262
+ })
263
+ const plan = buildGatherPlan(e, STRATUM, {quantity: 50}, OPTS)
264
+ expect(plan.totalLimpets).toBe(2)
265
+ expect(plan.reachingCount).toBe(2)
266
+ })
267
+
268
+ test('reachingCount excludes limpets that cannot reach the stratum', () => {
269
+ const e = entity({
270
+ gatherer_lanes: [
271
+ gathererLane(0, 700, 1250, 5000), // reaches 100
272
+ gathererLane(1, 700, 1250, 50), // does not reach 100
273
+ ],
274
+ generator: energyStats(60_000, 2),
275
+ energy: 60_000,
276
+ })
277
+ const plan = buildGatherPlan(e, STRATUM, {quantity: 50}, OPTS)
278
+ expect(plan.totalLimpets).toBe(2)
279
+ expect(plan.reachingCount).toBe(1)
280
+ })
281
+
282
+ test('single-limpet ship still fills across cycles', () => {
283
+ const CAP = 40
284
+ const e = entity({
285
+ gatherer_lanes: [gathererLane(0, 700, 1250, 5000)],
286
+ generator: energyStats(CAP, 2),
287
+ energy: CAP,
288
+ })
289
+ const plan = buildGatherPlan(e, STRATUM, {quantity: 300}, OPTS)
290
+ expect(plan.cycles.every((c) => c.limpets.length === 1)).toBe(true)
291
+ expect(plan.totalOre).toBe(300)
292
+ })
293
+
294
+ test('throws when no gatherer reaches the stratum', () => {
295
+ const e = entity({
296
+ gatherer_lanes: [gathererLane(0, 700, 1250, 50)],
297
+ generator: energyStats(1200, 2),
298
+ energy: 1200,
299
+ })
300
+ expect(() => buildGatherPlan(e, STRATUM, 'max', OPTS)).toThrow('no gatherer reaches')
301
+ })
302
+
303
+ test('zero fill target yields an empty plan (no cycles)', () => {
304
+ const e = entity({
305
+ gatherer_lanes: [gathererLane(0, 700, 1250, 5000)],
306
+ generator: energyStats(1200, 2),
307
+ energy: 1200,
308
+ })
309
+ const plan = buildGatherPlan(e, STRATUM, 'max', {...OPTS, holdRoom: 0})
310
+ expect(plan.cycleCount).toBe(0)
311
+ expect(plan.totalOre).toBe(0)
312
+ expect(plan.cap).toBe('hold')
313
+ })
314
+
315
+ test('first cycle skips recharge when current energy already covers the small batch', () => {
316
+ const e = entity({
317
+ gatherer_lanes: [gathererLane(0, 700, 1250, 5000)],
318
+ generator: energyStats(60_000, 2),
319
+ energy: 60_000, // full
320
+ })
321
+ const plan = buildGatherPlan(e, STRATUM, {quantity: 50}, OPTS)
322
+ expect(plan.cycles[0].rechargeBefore).toBe(false)
323
+ expect(plan.cycles[0].rechargeSeconds).toBe(0)
324
+ })
325
+ })
@@ -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,22 +1,50 @@
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 {
@@ -0,0 +1,48 @@
1
+ import {expect, test} from 'bun:test'
2
+ import {UInt16, UInt64} from '@wharfkit/antelope'
3
+ import {resolveItem} from './resolve-item'
4
+ import {encodeStats} from '../derivation/crafting'
5
+ import {computeBaseHullmass, computeContainerCapabilities} from '../derivation/capabilities'
6
+ import {
7
+ ITEM_EXTRACTOR_T1_PACKED,
8
+ ITEM_FACTORY_T1_PACKED,
9
+ ITEM_HUB_T1_PACKED,
10
+ ITEM_MASS_DRIVER_T1_PACKED,
11
+ ITEM_MASS_CATCHER_T1_PACKED,
12
+ } from '../data/item-ids'
13
+
14
+ function hullStats(strength: number, density: number, hardness: number): UInt64 {
15
+ return UInt64.from(encodeStats([strength, density, hardness]).toString())
16
+ }
17
+
18
+ const CONTAINER_ENTITIES = [
19
+ ['factory', ITEM_FACTORY_T1_PACKED],
20
+ ['extractor', ITEM_EXTRACTOR_T1_PACKED],
21
+ ['mass driver', ITEM_MASS_DRIVER_T1_PACKED],
22
+ ['mass catcher', ITEM_MASS_CATCHER_T1_PACKED],
23
+ ] as const
24
+
25
+ for (const [label, itemId] of CONTAINER_ENTITIES) {
26
+ test(`resolveItem resolves ${label} hull capacity via container formula`, () => {
27
+ const stats = hullStats(300, 100, 400)
28
+ const resolved = resolveItem(UInt16.from(itemId), stats)
29
+ const hull = resolved.attributes?.find((g) => g.capability === 'Hull')
30
+ const capacity = hull?.attributes.find((a) => a.label === 'Capacity')?.value
31
+ const expected = computeContainerCapabilities({
32
+ strength: 300,
33
+ hardness: 400,
34
+ density: 100,
35
+ }).capacity
36
+ expect(capacity).toBe(expected)
37
+ })
38
+ }
39
+
40
+ test('resolveItem resolves the station hub with hullmass and zero cargo capacity', () => {
41
+ const stats = hullStats(300, 100, 400)
42
+ const resolved = resolveItem(UInt16.from(ITEM_HUB_T1_PACKED), stats)
43
+ const hull = resolved.attributes?.find((g) => g.capability === 'Hull')
44
+ expect(hull?.attributes.find((a) => a.label === 'Mass')?.value).toBe(
45
+ computeBaseHullmass({density: 100})
46
+ )
47
+ expect(hull?.attributes.find((a) => a.label === 'Capacity')?.value).toBe(0)
48
+ })