@shipload/sdk 1.0.0-next.35 → 1.0.0-next.37

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 (52) hide show
  1. package/lib/shipload.d.ts +289 -80
  2. package/lib/shipload.js +3099 -2600
  3. package/lib/shipload.js.map +1 -1
  4. package/lib/shipload.m.js +3076 -2601
  5. package/lib/shipload.m.js.map +1 -1
  6. package/lib/testing.d.ts +66 -20
  7. package/lib/testing.js +95 -57
  8. package/lib/testing.js.map +1 -1
  9. package/lib/testing.m.js +95 -57
  10. package/lib/testing.m.js.map +1 -1
  11. package/package.json +1 -1
  12. package/src/capabilities/crafting.ts +2 -3
  13. package/src/capabilities/gathering.test.ts +16 -0
  14. package/src/capabilities/gathering.ts +8 -11
  15. package/src/contracts/server.ts +45 -29
  16. package/src/coordinates/address.ts +9 -5
  17. package/src/coordinates/constants.test.ts +15 -0
  18. package/src/coordinates/constants.ts +5 -3
  19. package/src/coordinates/index.ts +11 -0
  20. package/src/coordinates/memo.test.ts +47 -0
  21. package/src/coordinates/memo.ts +20 -0
  22. package/src/data/capability-formulas.ts +0 -1
  23. package/src/data/entities.json +4 -4
  24. package/src/data/items.json +5 -5
  25. package/src/data/recipes.json +39 -65
  26. package/src/derivation/capabilities.test.ts +133 -0
  27. package/src/derivation/capabilities.ts +66 -14
  28. package/src/derivation/rollups.test.ts +55 -0
  29. package/src/derivation/rollups.ts +56 -0
  30. package/src/entities/makers.ts +30 -3
  31. package/src/index-module.ts +30 -2
  32. package/src/managers/actions.ts +34 -3
  33. package/src/managers/construction.ts +6 -4
  34. package/src/managers/context.ts +9 -0
  35. package/src/managers/coordinates.ts +14 -0
  36. package/src/managers/plot.ts +2 -4
  37. package/src/nft/description.ts +25 -6
  38. package/src/planner/index.ts +127 -0
  39. package/src/planner/planner.test.ts +319 -0
  40. package/src/resolution/resolve-item.ts +4 -1
  41. package/src/scheduling/cancel.test.ts +21 -0
  42. package/src/scheduling/lanes.test.ts +249 -0
  43. package/src/scheduling/lanes.ts +140 -2
  44. package/src/scheduling/projection.ts +73 -16
  45. package/src/shipload.ts +5 -0
  46. package/src/testing/projection-parity.ts +26 -2
  47. package/src/travel/reach.ts +23 -0
  48. package/src/travel/route-planner.ts +157 -0
  49. package/src/travel/travel.ts +102 -101
  50. package/src/types/capabilities.ts +23 -6
  51. package/src/types/entity.ts +3 -3
  52. package/src/types.ts +1 -1
@@ -0,0 +1,20 @@
1
+ import type {Checksum256Type} from '@wharfkit/antelope'
2
+ import {Checksum256} from '@wharfkit/antelope'
3
+ import {type CoordinateAddress, encodeAddress} from './address'
4
+
5
+ const cache = new Map<string, CoordinateAddress>()
6
+ const CACHE_MAX = 4096
7
+
8
+ export function encodeAddressMemo(seed: Checksum256Type, x: number, y: number): CoordinateAddress {
9
+ const key = `${Checksum256.from(seed).toString()}:${x},${y}`
10
+ let hit = cache.get(key)
11
+ if (!hit) {
12
+ hit = encodeAddress(seed, x, y)
13
+ if (cache.size >= CACHE_MAX) {
14
+ const oldest = cache.keys().next().value
15
+ if (oldest !== undefined) cache.delete(oldest)
16
+ }
17
+ cache.set(key, hit)
18
+ }
19
+ return hit
20
+ }
@@ -23,7 +23,6 @@ const ENTITY_HULL_SLOTS: Record<number, SlotConsumer> = {
23
23
  0: {capability: 'Storage', attribute: 'capacity'},
24
24
  1: {capability: 'Hull', attribute: 'mass'},
25
25
  2: {capability: 'Storage', attribute: 'capacity'},
26
- 3: {capability: 'Storage', attribute: 'capacity'},
27
26
  }
28
27
 
29
28
  export const SLOT_FORMULAS: Record<SlotConsumerKind, Record<number, SlotConsumer>> = {
@@ -58,11 +58,11 @@
58
58
  "slots": [
59
59
  {
60
60
  "type": "generator",
61
- "outputPct": 100
61
+ "outputPct": 200
62
62
  },
63
63
  {
64
64
  "type": "gatherer",
65
- "outputPct": 100
65
+ "outputPct": 200
66
66
  }
67
67
  ]
68
68
  },
@@ -71,11 +71,11 @@
71
71
  "slots": [
72
72
  {
73
73
  "type": "generator",
74
- "outputPct": 100
74
+ "outputPct": 200
75
75
  },
76
76
  {
77
77
  "type": "crafter",
78
- "outputPct": 100
78
+ "outputPct": 200
79
79
  }
80
80
  ]
81
81
  },
@@ -474,31 +474,31 @@
474
474
  },
475
475
  {
476
476
  "id": 10200,
477
- "mass": 1300000,
477
+ "mass": 1900000,
478
478
  "type": "entity",
479
479
  "tier": 1
480
480
  },
481
481
  {
482
482
  "id": 10201,
483
- "mass": 1900000,
483
+ "mass": 2400000,
484
484
  "type": "entity",
485
485
  "tier": 1
486
486
  },
487
487
  {
488
488
  "id": 10202,
489
- "mass": 4800000,
489
+ "mass": 3200000,
490
490
  "type": "entity",
491
491
  "tier": 1
492
492
  },
493
493
  {
494
494
  "id": 10203,
495
- "mass": 3700000,
495
+ "mass": 1900000,
496
496
  "type": "entity",
497
497
  "tier": 1
498
498
  },
499
499
  {
500
500
  "id": 10204,
501
- "mass": 4600000,
501
+ "mass": 1900000,
502
502
  "type": "entity",
503
503
  "tier": 1
504
504
  },
@@ -651,15 +651,15 @@
651
651
  },
652
652
  {
653
653
  "outputItemId": 10200,
654
- "outputMass": 1300000,
654
+ "outputMass": 1900000,
655
655
  "inputs": [
656
656
  {
657
657
  "itemId": 10001,
658
658
  "quantity": 600
659
659
  },
660
660
  {
661
- "itemId": 10002,
662
- "quantity": 200
661
+ "itemId": 10008,
662
+ "quantity": 600
663
663
  }
664
664
  ],
665
665
  "statSlots": [
@@ -700,15 +700,27 @@
700
700
  },
701
701
  {
702
702
  "outputItemId": 10201,
703
- "outputMass": 1900000,
703
+ "outputMass": 2400000,
704
704
  "inputs": [
705
705
  {
706
706
  "itemId": 10001,
707
- "quantity": 800
707
+ "quantity": 300
708
708
  },
709
709
  {
710
- "itemId": 10002,
711
- "quantity": 400
710
+ "itemId": 10008,
711
+ "quantity": 300
712
+ },
713
+ {
714
+ "itemId": 10007,
715
+ "quantity": 300
716
+ },
717
+ {
718
+ "itemId": 10003,
719
+ "quantity": 300
720
+ },
721
+ {
722
+ "itemId": 10006,
723
+ "quantity": 300
712
724
  }
713
725
  ],
714
726
  "statSlots": [
@@ -729,34 +741,24 @@
729
741
  ]
730
742
  },
731
743
  {
732
- "sources": [
733
- {
734
- "inputIndex": 1,
735
- "statIndex": 0
736
- }
737
- ]
744
+ "sources": []
738
745
  },
739
746
  {
740
- "sources": [
741
- {
742
- "inputIndex": 1,
743
- "statIndex": 1
744
- }
745
- ]
747
+ "sources": []
746
748
  }
747
749
  ],
748
750
  "blendWeights": []
749
751
  },
750
752
  {
751
753
  "outputItemId": 10202,
752
- "outputMass": 4800000,
754
+ "outputMass": 3200000,
753
755
  "inputs": [
754
756
  {
755
757
  "itemId": 10001,
756
- "quantity": 2000
758
+ "quantity": 1000
757
759
  },
758
760
  {
759
- "itemId": 10002,
761
+ "itemId": 10008,
760
762
  "quantity": 1000
761
763
  }
762
764
  ],
@@ -798,25 +800,20 @@
798
800
  },
799
801
  {
800
802
  "outputItemId": 10203,
801
- "outputMass": 3700000,
803
+ "outputMass": 1900000,
802
804
  "inputs": [
803
805
  {
804
- "itemId": 10001,
805
- "quantity": 1500
806
+ "itemId": 10008,
807
+ "quantity": 600
806
808
  },
807
809
  {
808
- "itemId": 10002,
809
- "quantity": 800
810
+ "itemId": 10006,
811
+ "quantity": 600
810
812
  }
811
813
  ],
812
814
  "statSlots": [
813
815
  {
814
- "sources": [
815
- {
816
- "inputIndex": 0,
817
- "statIndex": 0
818
- }
819
- ]
816
+ "sources": []
820
817
  },
821
818
  {
822
819
  "sources": [
@@ -829,51 +826,33 @@
829
826
  {
830
827
  "sources": [
831
828
  {
832
- "inputIndex": 1,
829
+ "inputIndex": 0,
833
830
  "statIndex": 0
834
831
  }
835
832
  ]
836
833
  },
837
834
  {
838
- "sources": [
839
- {
840
- "inputIndex": 1,
841
- "statIndex": 1
842
- }
843
- ]
835
+ "sources": []
844
836
  }
845
837
  ],
846
838
  "blendWeights": []
847
839
  },
848
840
  {
849
841
  "outputItemId": 10204,
850
- "outputMass": 4600000,
842
+ "outputMass": 1900000,
851
843
  "inputs": [
852
- {
853
- "itemId": 10001,
854
- "quantity": 1500
855
- },
856
- {
857
- "itemId": 10002,
858
- "quantity": 600
859
- },
860
844
  {
861
845
  "itemId": 10008,
862
- "quantity": 400
846
+ "quantity": 600
863
847
  },
864
848
  {
865
- "itemId": 10009,
866
- "quantity": 400
849
+ "itemId": 10007,
850
+ "quantity": 600
867
851
  }
868
852
  ],
869
853
  "statSlots": [
870
854
  {
871
- "sources": [
872
- {
873
- "inputIndex": 0,
874
- "statIndex": 0
875
- }
876
- ]
855
+ "sources": []
877
856
  },
878
857
  {
879
858
  "sources": [
@@ -886,18 +865,13 @@
886
865
  {
887
866
  "sources": [
888
867
  {
889
- "inputIndex": 1,
868
+ "inputIndex": 0,
890
869
  "statIndex": 0
891
870
  }
892
871
  ]
893
872
  },
894
873
  {
895
- "sources": [
896
- {
897
- "inputIndex": 1,
898
- "statIndex": 1
899
- }
900
- ]
874
+ "sources": []
901
875
  }
902
876
  ],
903
877
  "blendWeights": []
@@ -0,0 +1,133 @@
1
+ import {expect, test} from 'bun:test'
2
+ import {
3
+ computeEntityCapabilities,
4
+ computeGathererCapabilities,
5
+ computeCrafterCapabilities,
6
+ computeLoaderCapabilities,
7
+ } from './capabilities'
8
+ import {applySlotMultiplier, U16_MAX} from '../entities/slot-multiplier'
9
+ import {encodeStats} from './crafting'
10
+ import {
11
+ ITEM_EXTRACTOR_T1_PACKED,
12
+ ITEM_GATHERER_T1,
13
+ ITEM_CRAFTER_T1,
14
+ ITEM_LOADER_T1,
15
+ } from '../data/item-ids'
16
+ import type {InstalledModule} from '../entities/slot-multiplier'
17
+ import type {EntitySlot} from '../data/recipes-runtime'
18
+
19
+ function makeGathererStats(strength: number, tolerance: number, conductivity: number): bigint {
20
+ return encodeStats([strength, tolerance, conductivity, 0])
21
+ }
22
+
23
+ function makeCrafterStats(reactivity: number, fineness: number): bigint {
24
+ return encodeStats([reactivity, fineness])
25
+ }
26
+
27
+ function makeLoaderStats(insulation: number, plasticity: number): bigint {
28
+ return encodeStats([insulation, plasticity])
29
+ }
30
+
31
+ test('computeEntityCapabilities emits gathererLanes alongside legacy gatherer sum', () => {
32
+ // Two gatherers with distinct stats in separate slots, amp=100 for both
33
+ const gathStats1 = makeGathererStats(300, 200, 400)
34
+ const gathStats2 = makeGathererStats(500, 100, 300)
35
+
36
+ const modules: InstalledModule[] = [
37
+ {slotIndex: 0, itemId: ITEM_GATHERER_T1, stats: gathStats1},
38
+ {slotIndex: 1, itemId: ITEM_GATHERER_T1, stats: gathStats2},
39
+ ]
40
+
41
+ const layout: EntitySlot[] = [
42
+ {type: 'gatherer', outputPct: 100},
43
+ {type: 'gatherer', outputPct: 100},
44
+ ]
45
+
46
+ const result = computeEntityCapabilities({}, ITEM_EXTRACTOR_T1_PACKED, modules, layout)
47
+
48
+ // Lane lists must exist
49
+ expect(result.gathererLanes).toBeDefined()
50
+ expect(result.gathererLanes!.length).toBe(2)
51
+
52
+ // Each lane has the right slotIndex
53
+ expect(result.gathererLanes![0].slotIndex).toBe(0)
54
+ expect(result.gathererLanes![1].slotIndex).toBe(1)
55
+
56
+ // Yields are amp-scaled and distinct
57
+ const caps1 = computeGathererCapabilities({strength: 300, tolerance: 200, conductivity: 400}, 1)
58
+ const caps2 = computeGathererCapabilities({strength: 500, tolerance: 100, conductivity: 300}, 1)
59
+ const expectedYield1 = applySlotMultiplier(caps1.yield, 100)
60
+ const expectedYield2 = applySlotMultiplier(caps2.yield, 100)
61
+ expect(result.gathererLanes![0].yield).toBe(expectedYield1)
62
+ expect(result.gathererLanes![1].yield).toBe(expectedYield2)
63
+ expect(result.gathererLanes![0].yield).not.toBe(result.gathererLanes![1].yield)
64
+
65
+ // Unscaled per-module drain and depth carried verbatim from the compute helper
66
+ expect(result.gathererLanes![0].drain).toBe(caps1.drain)
67
+ expect(result.gathererLanes![1].drain).toBe(caps2.drain)
68
+ expect(result.gathererLanes![0].depth).toBe(caps1.depth)
69
+ expect(result.gathererLanes![1].depth).toBe(caps2.depth)
70
+
71
+ // outputPct reflects the slot amp
72
+ expect(result.gathererLanes![0].outputPct).toBe(100)
73
+ expect(result.gathererLanes![1].outputPct).toBe(100)
74
+
75
+ // Legacy sum still equals sum of both lane yields
76
+ expect(result.gatherer).toBeDefined()
77
+ expect(result.gatherer!.yield).toBe(expectedYield1 + expectedYield2)
78
+ })
79
+
80
+ test('computeEntityCapabilities emits crafterLanes alongside legacy crafter sum', () => {
81
+ const crafterStats = makeCrafterStats(400, 300)
82
+
83
+ const modules: InstalledModule[] = [
84
+ {slotIndex: 0, itemId: ITEM_CRAFTER_T1, stats: crafterStats},
85
+ ]
86
+
87
+ const layout: EntitySlot[] = [{type: 'crafter', outputPct: 120}]
88
+
89
+ const result = computeEntityCapabilities({}, ITEM_EXTRACTOR_T1_PACKED, modules, layout)
90
+
91
+ expect(result.crafterLanes).toBeDefined()
92
+ expect(result.crafterLanes!.length).toBe(1)
93
+ expect(result.crafterLanes![0].slotIndex).toBe(0)
94
+
95
+ const caps = computeCrafterCapabilities({reactivity: 400, fineness: 300})
96
+ const expectedSpeed = applySlotMultiplier(caps.speed, 120)
97
+ expect(result.crafterLanes![0].speed).toBe(expectedSpeed)
98
+ expect(result.crafterLanes![0].drain).toBe(caps.drain)
99
+ expect(result.crafterLanes![0].outputPct).toBe(120)
100
+
101
+ // Legacy crafter speed equals single-lane speed
102
+ expect(result.crafter).toBeDefined()
103
+ expect(result.crafter!.speed).toBe(expectedSpeed)
104
+ })
105
+
106
+ test('computeEntityCapabilities emits loaderLanes alongside legacy loaders sum', () => {
107
+ const loaderStats = makeLoaderStats(600, 500)
108
+
109
+ const modules: InstalledModule[] = [{slotIndex: 0, itemId: ITEM_LOADER_T1, stats: loaderStats}]
110
+
111
+ const layout: EntitySlot[] = [{type: 'loader', outputPct: 80}]
112
+
113
+ const result = computeEntityCapabilities({}, ITEM_EXTRACTOR_T1_PACKED, modules, layout)
114
+
115
+ expect(result.loaderLanes).toBeDefined()
116
+ expect(result.loaderLanes!.length).toBe(1)
117
+ expect(result.loaderLanes![0].slotIndex).toBe(0)
118
+
119
+ const caps = computeLoaderCapabilities({insulation: 600, plasticity: 500})
120
+ // mass is unscaled (raw); thrust is amp-scaled
121
+ expect(result.loaderLanes![0].mass).toBe(caps.mass)
122
+ expect(result.loaderLanes![0].thrust).toBe(applySlotMultiplier(caps.thrust, 80))
123
+ expect(result.loaderLanes![0].outputPct).toBe(80)
124
+
125
+ // Legacy loaders.mass is total (same as single-lane raw mass here)
126
+ expect(result.loaders).toBeDefined()
127
+ expect(result.loaders!.mass).toBe(caps.mass)
128
+ })
129
+
130
+ test('per-lane amp-scaled stats clamp to UInt16, matching the contract clamp_to_uint16', () => {
131
+ expect(applySlotMultiplier(60000, 200)).toBe(U16_MAX)
132
+ expect(applySlotMultiplier(1000, 150)).toBe(1500)
133
+ })
@@ -6,8 +6,8 @@ export function computeShipHullCapabilities(stats: Record<string, number>): {
6
6
  hullmass: number
7
7
  capacity: number
8
8
  } {
9
- const statSum = stats.strength + stats.hardness + stats.cohesion
10
- const exponent = statSum / 2997.0
9
+ const statSum = (stats.strength ?? 0) + (stats.hardness ?? 0)
10
+ const exponent = statSum / 1998.0
11
11
  return {
12
12
  hullmass: computeBaseHullmass(stats),
13
13
  capacity: Math.floor(5000000 * 6 ** exponent),
@@ -184,9 +184,9 @@ import type {EntitySlot} from '../data/recipes-runtime'
184
184
  export function computeBaseCapacity(itemId: number, stats: Record<string, number>): number {
185
185
  switch (itemId) {
186
186
  case ITEM_SHIP_T1_PACKED:
187
+ return computeShipHullCapabilities(stats).capacity
187
188
  case ITEM_EXTRACTOR_T1_PACKED:
188
189
  case ITEM_FACTORY_T1_PACKED:
189
- return computeShipHullCapabilities(stats).capacity
190
190
  case ITEM_CONTAINER_T1_PACKED:
191
191
  return computeContainerCapabilities(stats).capacity
192
192
  case ITEM_WAREHOUSE_T1_PACKED:
@@ -209,22 +209,47 @@ export function computeWarehouseHullCapabilities(stats: Record<string, number>):
209
209
  hullmass: number
210
210
  capacity: number
211
211
  } {
212
- const statSum = stats.strength + stats.hardness + stats.cohesion
213
- const exponent = statSum / 2997.0
212
+ const statSum = (stats.strength ?? 0) + (stats.hardness ?? 0)
213
+ const exponent = statSum / 1998.0
214
214
  return {
215
215
  hullmass: computeBaseHullmass(stats),
216
216
  capacity: Math.floor(100000000 * 6 ** exponent),
217
217
  }
218
218
  }
219
219
 
220
+ export interface GathererLaneEntry {
221
+ slotIndex: number
222
+ yield: number
223
+ drain: number
224
+ depth: number
225
+ outputPct: number
226
+ }
227
+
228
+ export interface CrafterLaneEntry {
229
+ slotIndex: number
230
+ speed: number
231
+ drain: number
232
+ outputPct: number
233
+ }
234
+
235
+ export interface LoaderLaneEntry {
236
+ slotIndex: number
237
+ mass: number
238
+ thrust: number
239
+ outputPct: number
240
+ }
241
+
220
242
  export interface ComputedCapabilities {
221
243
  hullmass: number
222
244
  capacity: number
223
245
  engines?: {thrust: number; drain: number}
224
246
  generator?: {capacity: number; recharge: number}
225
247
  gatherer?: {yield: number; drain: number; depth: number}
248
+ gathererLanes?: GathererLaneEntry[]
226
249
  loaders?: {mass: number; thrust: number; quantity: number}
250
+ loaderLanes?: LoaderLaneEntry[]
227
251
  crafter?: {speed: number; drain: number}
252
+ crafterLanes?: CrafterLaneEntry[]
228
253
  hauler?: {capacity: number; efficiency: number; drain: number}
229
254
  warp?: {range: number}
230
255
  }
@@ -272,6 +297,10 @@ export function computeEntityCapabilities(
272
297
  let totalBatteryStatSum = 0
273
298
  let batteryCount = 0
274
299
 
300
+ const gathererLanes: GathererLaneEntry[] = []
301
+ const crafterLanes: CrafterLaneEntry[] = []
302
+ const loaderLanes: LoaderLaneEntry[] = []
303
+
275
304
  for (const mod of modules) {
276
305
  const item = getItem(mod.itemId)
277
306
  const modType = getModuleCapabilityType(mod.itemId)
@@ -293,23 +322,44 @@ export function computeEntityCapabilities(
293
322
  hasGatherer = true
294
323
  const tier = item.tier
295
324
  const caps = computeGathererCapabilities(decodedStats, tier)
296
- totalGathYield += applySlotMultiplier(caps.yield, amp)
325
+ const scaledYield = applySlotMultiplier(caps.yield, amp)
326
+ totalGathYield += scaledYield
297
327
  totalGathDrain += caps.drain
298
328
  if (caps.depth > maxGathDepth) maxGathDepth = caps.depth
329
+ gathererLanes.push({
330
+ slotIndex: mod.slotIndex,
331
+ yield: scaledYield,
332
+ drain: caps.drain,
333
+ depth: caps.depth,
334
+ outputPct: amp,
335
+ })
299
336
  } else if (modType === MODULE_LOADER) {
300
337
  hasLoader = true
301
338
  const caps = computeLoaderCapabilities(decodedStats)
302
339
  totalLoaderMass += caps.mass
303
340
  totalLoaderThrust += applySlotMultiplier(caps.thrust, amp)
304
341
  totalLoaderQuantity += caps.quantity
342
+ loaderLanes.push({
343
+ slotIndex: mod.slotIndex,
344
+ mass: caps.mass,
345
+ thrust: applySlotMultiplier(caps.thrust, amp),
346
+ outputPct: amp,
347
+ })
305
348
  } else if (modType === MODULE_STORAGE) {
306
349
  const caps = computeStorageCapabilities(decodedStats, baseCapacity)
307
350
  totalStorageBonus += caps.capacityBonus
308
351
  } else if (modType === MODULE_CRAFTER) {
309
352
  hasCrafter = true
310
353
  const caps = computeCrafterCapabilities(decodedStats)
311
- totalCrafterSpeed += applySlotMultiplier(caps.speed, amp)
354
+ const scaledSpeed = applySlotMultiplier(caps.speed, amp)
355
+ totalCrafterSpeed += scaledSpeed
312
356
  totalCrafterDrain += caps.drain
357
+ crafterLanes.push({
358
+ slotIndex: mod.slotIndex,
359
+ speed: scaledSpeed,
360
+ drain: caps.drain,
361
+ outputPct: amp,
362
+ })
313
363
  } else if (modType === MODULE_HAULER) {
314
364
  hasHauler = true
315
365
  const caps = computeHaulerCapabilities(decodedStats)
@@ -357,6 +407,7 @@ export function computeEntityCapabilities(
357
407
  drain: totalGathDrain,
358
408
  depth: maxGathDepth,
359
409
  }
410
+ result.gathererLanes = gathererLanes
360
411
  }
361
412
  if (hasLoader) {
362
413
  result.loaders = {
@@ -364,9 +415,11 @@ export function computeEntityCapabilities(
364
415
  thrust: clampUint16(totalLoaderThrust),
365
416
  quantity: totalLoaderQuantity,
366
417
  }
418
+ result.loaderLanes = loaderLanes
367
419
  }
368
420
  if (hasCrafter) {
369
421
  result.crafter = {speed: clampUint16(totalCrafterSpeed), drain: totalCrafterDrain}
422
+ result.crafterLanes = crafterLanes
370
423
  }
371
424
  if (hasHauler) {
372
425
  const efficiency =
@@ -388,8 +441,8 @@ export function computeContainerCapabilities(stats: Record<string, number>): {
388
441
  hullmass: number
389
442
  capacity: number
390
443
  } {
391
- const statSum = stats.strength + stats.hardness + stats.cohesion
392
- const exponent = statSum / 2997.0
444
+ const statSum = (stats.strength ?? 0) + (stats.hardness ?? 0)
445
+ const exponent = statSum / 1998.0
393
446
  return {
394
447
  hullmass: computeBaseHullmass(stats),
395
448
  capacity: Math.floor(22000000 * 6 ** exponent),
@@ -400,14 +453,13 @@ export function computeContainerT2Capabilities(stats: Record<string, number>): {
400
453
  hullmass: number
401
454
  capacity: number
402
455
  } {
403
- const strength = stats.strength
404
- const density = stats.density
405
- const hardness = stats.hardness
406
- const cohesion = stats.cohesion
456
+ const strength = stats.strength ?? 0
457
+ const density = stats.density ?? 0
458
+ const hardness = stats.hardness ?? 0
407
459
 
408
460
  const hullmass = 70000 - 50 * density
409
461
 
410
- const statSum = strength + hardness + cohesion
462
+ const statSum = strength + hardness
411
463
  const exponent = statSum / 2947
412
464
  const capacity = Math.floor(24000000 * 6 ** exponent)
413
465
 
@@ -0,0 +1,55 @@
1
+ import {describe, expect, test} from 'bun:test'
2
+ import {ServerContract} from '../contracts'
3
+ import {rollupGatherer, rollupCrafter, rollupLoaders} from './rollups'
4
+
5
+ const gLane = (slot: number, y: number, d: number, depth: number) =>
6
+ ServerContract.Types.gatherer_lane.from({
7
+ slot_index: slot,
8
+ yield: y,
9
+ drain: d,
10
+ depth,
11
+ output_pct: 100,
12
+ })
13
+ const cLane = (slot: number, s: number, d: number) =>
14
+ ServerContract.Types.crafter_lane.from({slot_index: slot, speed: s, drain: d, output_pct: 100})
15
+ const lLane = (slot: number, m: number, t: number) =>
16
+ ServerContract.Types.loader_lane.from({slot_index: slot, mass: m, thrust: t, output_pct: 100})
17
+
18
+ describe('rollupGatherer', () => {
19
+ test('empty → undefined', () => {
20
+ expect(rollupGatherer([])).toBeUndefined()
21
+ })
22
+ test('sums yield/drain, takes MAX depth', () => {
23
+ const r = rollupGatherer([gLane(2, 300, 1250, 500), gLane(3, 250, 1000, 5495)])!
24
+ expect(r.yield.toNumber()).toBe(550)
25
+ expect(r.drain.toNumber()).toBe(2250)
26
+ expect(r.depth.toNumber()).toBe(5495)
27
+ })
28
+ test('clamps summed yield to uint16', () => {
29
+ const r = rollupGatherer([gLane(2, 60000, 0, 1), gLane(3, 60000, 0, 1)])!
30
+ expect(r.yield.toNumber()).toBe(65535)
31
+ })
32
+ })
33
+
34
+ describe('rollupCrafter', () => {
35
+ test('empty → undefined', () => {
36
+ expect(rollupCrafter([])).toBeUndefined()
37
+ })
38
+ test('sums speed/drain', () => {
39
+ const r = rollupCrafter([cLane(2, 100, 30), cLane(3, 140, 25)])!
40
+ expect(r.speed.toNumber()).toBe(240)
41
+ expect(r.drain.toNumber()).toBe(55)
42
+ })
43
+ })
44
+
45
+ describe('rollupLoaders', () => {
46
+ test('empty → undefined', () => {
47
+ expect(rollupLoaders([])).toBeUndefined()
48
+ })
49
+ test('integer-averages mass, sums thrust, counts quantity', () => {
50
+ const r = rollupLoaders([lLane(2, 200, 5), lLane(3, 201, 7)])!
51
+ expect(r.mass.toNumber()).toBe(200) // floor(401/2)
52
+ expect(r.thrust.toNumber()).toBe(12)
53
+ expect(r.quantity.toNumber()).toBe(2)
54
+ })
55
+ })