@shipload/sdk 1.0.0-next.51 → 1.0.0-next.52

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 (58) hide show
  1. package/lib/shipload.d.ts +470 -157
  2. package/lib/shipload.js +2309 -492
  3. package/lib/shipload.js.map +1 -1
  4. package/lib/shipload.m.js +2279 -491
  5. package/lib/shipload.m.js.map +1 -1
  6. package/lib/testing.d.ts +123 -8
  7. package/lib/testing.js +450 -19
  8. package/lib/testing.js.map +1 -1
  9. package/lib/testing.m.js +451 -20
  10. package/lib/testing.m.js.map +1 -1
  11. package/package.json +1 -1
  12. package/src/capabilities/crafting.ts +10 -1
  13. package/src/capabilities/gathering.ts +1 -1
  14. package/src/capabilities/hauling.ts +0 -5
  15. package/src/capabilities/modules.ts +6 -0
  16. package/src/capabilities/movement.ts +1 -1
  17. package/src/contracts/server.ts +293 -13
  18. package/src/data/capabilities.ts +11 -2
  19. package/src/data/entities.json +235 -20
  20. package/src/data/item-ids.ts +10 -0
  21. package/src/data/items.json +61 -0
  22. package/src/data/kind-registry.json +53 -1
  23. package/src/data/kind-registry.ts +5 -0
  24. package/src/data/metadata.ts +74 -5
  25. package/src/data/recipes-runtime.ts +1 -0
  26. package/src/data/recipes.json +887 -118
  27. package/src/derivation/capabilities.test.ts +81 -4
  28. package/src/derivation/capabilities.ts +162 -57
  29. package/src/derivation/crafting.ts +2 -0
  30. package/src/derivation/recipe-usage.test.ts +10 -7
  31. package/src/derivation/rollups.ts +16 -0
  32. package/src/derivation/stat-scaling.ts +12 -0
  33. package/src/entities/makers.ts +10 -1
  34. package/src/index-module.ts +29 -3
  35. package/src/managers/actions.ts +77 -46
  36. package/src/managers/construction-types.ts +2 -2
  37. package/src/managers/construction.ts +15 -15
  38. package/src/managers/plot.ts +1 -1
  39. package/src/nft/buildImmutableData.ts +55 -9
  40. package/src/nft/description.ts +99 -36
  41. package/src/planner/planner.test.ts +50 -41
  42. package/src/resolution/resolve-item.test.ts +1 -1
  43. package/src/resolution/resolve-item.ts +26 -11
  44. package/src/scheduling/availability.ts +7 -6
  45. package/src/scheduling/cancel.test.ts +40 -6
  46. package/src/scheduling/cancel.ts +21 -29
  47. package/src/scheduling/jobs.ts +71 -0
  48. package/src/scheduling/lanes.ts +29 -0
  49. package/src/scheduling/projection.ts +42 -25
  50. package/src/scheduling/task-cargo.ts +1 -1
  51. package/src/testing/projection-parity.ts +2 -2
  52. package/src/travel/reach.ts +4 -6
  53. package/src/travel/route-planner.ts +60 -49
  54. package/src/travel/route-simulator.ts +3 -7
  55. package/src/travel/travel.ts +14 -5
  56. package/src/types/capabilities.ts +9 -3
  57. package/src/types.ts +5 -3
  58. package/src/managers/flatten-gather-plan.test.ts +0 -80
@@ -1,7 +1,9 @@
1
1
  import {expect, test} from 'bun:test'
2
2
  import {
3
3
  computeEntityCapabilities,
4
+ computeEngineCapabilities,
4
5
  computeGathererCapabilities,
6
+ computeGeneratorCapabilities,
5
7
  computeCrafterCapabilities,
6
8
  computeLoaderCapabilities,
7
9
  computeBaseCapacity,
@@ -17,12 +19,25 @@ import {
17
19
  ITEM_GATHERER_T1,
18
20
  ITEM_CRAFTER_T1,
19
21
  ITEM_LOADER_T1,
22
+ ITEM_SHIP_T1_PACKED,
23
+ ITEM_ROUSTABOUT_T1_PACKED,
24
+ ITEM_PROSPECTOR_T1_PACKED,
25
+ ITEM_TENDER_T1_PACKED,
26
+ ITEM_TUG_T1_PACKED,
27
+ ITEM_PORTER_T1_PACKED,
28
+ ITEM_WRANGLER_T1_PACKED,
29
+ ITEM_DREDGER_T1_PACKED,
30
+ ITEM_WRIGHT_T1_PACKED,
31
+ ITEM_ENGINE_T1,
32
+ ITEM_GENERATOR_T1,
20
33
  } from '../data/item-ids'
21
34
  import type {InstalledModule} from '../entities/slot-multiplier'
22
35
  import type {EntitySlot} from '../data/recipes-runtime'
36
+ import {computeTravelDrain} from '../nft/description'
37
+ import {computeEffectiveModuleStat} from './stat-scaling'
23
38
 
24
- function makeGathererStats(strength: number, hardness: number, saturation: number): bigint {
25
- return encodeStats([strength, hardness, saturation, 0])
39
+ function makeGathererStats(strength: number, tolerance: number, saturation: number): bigint {
40
+ return encodeStats([strength, tolerance, saturation, 0])
26
41
  }
27
42
 
28
43
  function makeCrafterStats(fineness: number, conductivity: number): bigint {
@@ -46,6 +61,24 @@ test('computeBaseCapacity uses container formula for all container-class entitie
46
61
  }
47
62
  })
48
63
 
64
+ test('computeBaseCapacity uses ship-hull formula for every ship-class entity', () => {
65
+ const stats = {strength: 300, hardness: 400, density: 100}
66
+ const expected = computeBaseCapacity(ITEM_SHIP_T1_PACKED, stats)
67
+ expect(expected).toBeGreaterThan(0)
68
+ for (const itemId of [
69
+ ITEM_ROUSTABOUT_T1_PACKED,
70
+ ITEM_PROSPECTOR_T1_PACKED,
71
+ ITEM_TENDER_T1_PACKED,
72
+ ITEM_TUG_T1_PACKED,
73
+ ITEM_PORTER_T1_PACKED,
74
+ ITEM_WRANGLER_T1_PACKED,
75
+ ITEM_DREDGER_T1_PACKED,
76
+ ITEM_WRIGHT_T1_PACKED,
77
+ ]) {
78
+ expect(computeBaseCapacity(itemId, stats)).toBe(expected)
79
+ }
80
+ })
81
+
49
82
  test('computeEntityCapabilities emits gathererLanes alongside legacy gatherer sum', () => {
50
83
  // Two gatherers with distinct stats in separate slots, amp=100 for both
51
84
  const gathStats1 = makeGathererStats(300, 200, 400)
@@ -72,8 +105,8 @@ test('computeEntityCapabilities emits gathererLanes alongside legacy gatherer su
72
105
  expect(result.gathererLanes![1].slotIndex).toBe(1)
73
106
 
74
107
  // Yields are amp-scaled and distinct
75
- const caps1 = computeGathererCapabilities({strength: 300, hardness: 200, saturation: 400}, 1)
76
- const caps2 = computeGathererCapabilities({strength: 500, hardness: 100, saturation: 300}, 1)
108
+ const caps1 = computeGathererCapabilities({strength: 300, tolerance: 200, saturation: 400}, 1)
109
+ const caps2 = computeGathererCapabilities({strength: 500, tolerance: 100, saturation: 300}, 1)
77
110
  const expectedYield1 = applySlotMultiplier(caps1.yield, 100)
78
111
  const expectedYield2 = applySlotMultiplier(caps2.yield, 100)
79
112
  expect(result.gathererLanes![0].yield).toBe(expectedYield1)
@@ -149,3 +182,47 @@ test('per-lane amp-scaled stats clamp to UInt16, matching the contract clamp_to_
149
182
  expect(applySlotMultiplier(60000, 200)).toBe(U16_MAX)
150
183
  expect(applySlotMultiplier(1000, 150)).toBe(1500)
151
184
  })
185
+
186
+ test('generator capacity and recharge are denominated to milli-energy', () => {
187
+ const caps = computeGeneratorCapabilities({resonance: 213, reflectivity: 213})
188
+ expect(caps.capacity).toBe(1_406_500)
189
+ expect(caps.recharge).toBe(3_278)
190
+ })
191
+
192
+ test('engine and generator capabilities use tapered quality consistently', () => {
193
+ const modules: InstalledModule[] = [
194
+ {slotIndex: 0, itemId: ITEM_ENGINE_T1, stats: encodeStats([500, 500])},
195
+ {slotIndex: 1, itemId: ITEM_GENERATOR_T1, stats: encodeStats([500, 500])},
196
+ ]
197
+ const layout: EntitySlot[] = [
198
+ {type: 'engine', outputPct: 100, maxTier: 1},
199
+ {type: 'generator', outputPct: 100, maxTier: 1},
200
+ ]
201
+
202
+ const result = computeEntityCapabilities({}, ITEM_ROUSTABOUT_T1_PACKED, modules, layout)
203
+ const engines = computeEngineCapabilities({volatility: 500, thermal: 500})
204
+
205
+ expect(result.engines).toEqual({
206
+ thrust: engines.thrust,
207
+ drain: computeTravelDrain(engines.thrust, computeEffectiveModuleStat(500)),
208
+ })
209
+ expect(result.generator).toEqual(
210
+ computeGeneratorCapabilities({resonance: 500, reflectivity: 500})
211
+ )
212
+ })
213
+
214
+ test('gatherer/crafter/hauler drains are denominated', () => {
215
+ expect(computeGathererCapabilities({strength: 0, hardness: 0, saturation: 213}, 1).drain).toBe(
216
+ 1_967_500
217
+ )
218
+ expect(computeCrafterCapabilities({fineness: 0, conductivity: 213}).drain).toBe(23_546)
219
+ })
220
+
221
+ test('gatherer depth accepts canonical tolerance and legacy recipe-labelled hardness', () => {
222
+ expect(computeGathererCapabilities({strength: 0, tolerance: 213, saturation: 0}, 2).depth).toBe(
223
+ 4_343
224
+ )
225
+ expect(computeGathererCapabilities({strength: 0, hardness: 213, saturation: 0}, 1).depth).toBe(
226
+ 1_565
227
+ )
228
+ })
@@ -1,15 +1,40 @@
1
- export function computeBaseHullmass(stats: Record<string, number>): number {
2
- return 100000 - 75 * stats.density
1
+ import {getEntityLayout} from '../data/recipes-runtime'
2
+ import {ENTITY_SHIP, getPackedEntityType} from '../data/kind-registry'
3
+ import {computeEffectiveModuleStat} from './stat-scaling'
4
+
5
+ export const DEFAULT_BASE_HULLMASS = 100_000
6
+
7
+ export function getBaseHullmassFor(itemId: number): number {
8
+ return getEntityLayout(itemId)?.baseHullmass ?? DEFAULT_BASE_HULLMASS
9
+ }
10
+
11
+ function isShipHull(itemId: number): boolean {
12
+ return getPackedEntityType(itemId)?.equals(ENTITY_SHIP) ?? false
3
13
  }
4
14
 
5
- export function computeShipHullCapabilities(stats: Record<string, number>): {
15
+ function sumHullStats(stats: Record<string, number>): number {
16
+ return Object.values(stats).reduce((sum, value) => sum + value, 0)
17
+ }
18
+
19
+ export function computeBaseHullmass(itemId: number, stats: Record<string, number>): number {
20
+ if (isShipHull(itemId)) {
21
+ return Math.floor((getBaseHullmassFor(itemId) * (10_000 - sumHullStats(stats))) / 10_000)
22
+ }
23
+ const lightness = stats.density ?? stats.hardness ?? 0
24
+ return Math.floor((getBaseHullmassFor(itemId) * (2000 - lightness)) / 2000)
25
+ }
26
+
27
+ export function computeShipHullCapabilities(
28
+ stats: Record<string, number>,
29
+ itemId: number = ITEM_SHIP_T1_PACKED
30
+ ): {
6
31
  hullmass: number
7
32
  capacity: number
8
33
  } {
9
- const statSum = (stats.strength ?? 0) + (stats.hardness ?? 0)
10
- const exponent = statSum / 1998.0
34
+ const statSum = sumHullStats(stats)
35
+ const exponent = statSum / 4995.0
11
36
  return {
12
- hullmass: computeBaseHullmass(stats),
37
+ hullmass: computeBaseHullmass(itemId, stats),
13
38
  capacity: Math.floor(5000000 * 6 ** exponent),
14
39
  }
15
40
  }
@@ -18,8 +43,8 @@ export function computeEngineCapabilities(stats: Record<string, number>): {
18
43
  thrust: number
19
44
  drain: number
20
45
  } {
21
- const vol = stats.volatility
22
- const thm = stats.thermal
46
+ const vol = computeEffectiveModuleStat(stats.volatility)
47
+ const thm = computeEffectiveModuleStat(stats.thermal)
23
48
 
24
49
  return {
25
50
  thrust: 400 + Math.floor((vol * 3) / 4),
@@ -31,12 +56,12 @@ export function computeGeneratorCapabilities(stats: Record<string, number>): {
31
56
  capacity: number
32
57
  recharge: number
33
58
  } {
34
- const res = stats.resonance
35
- const ref = stats.reflectivity
59
+ const res = computeEffectiveModuleStat(stats.resonance)
60
+ const ref = computeEffectiveModuleStat(stats.reflectivity)
36
61
 
37
62
  return {
38
- capacity: 1300 + Math.floor(res / 2),
39
- recharge: 2 * (1 + Math.floor((ref * 3) / 1000)),
63
+ capacity: 1_300_000 + res * 500,
64
+ recharge: 2000 + ref * 6,
40
65
  }
41
66
  }
42
67
 
@@ -78,12 +103,15 @@ export function computeGathererCapabilities(
78
103
  } {
79
104
  const str = stats.strength
80
105
  const con = stats.saturation
81
- const hrd = stats.hardness
106
+ // Gatherer capabilities are positional in the contract: packed slot 1 is tolerance.
107
+ // T1 recipe presentation historically labels that blended slot as hardness, so retain
108
+ // that fallback for callers that only have recipe-decoded named stats.
109
+ const tol = stats.tolerance ?? stats.hardness
82
110
 
83
111
  return {
84
112
  yield: 200 + str,
85
- drain: 2 * Math.max(250, 1250 - Math.floor((con * 25) / 20)),
86
- depth: gathererDepthForTier(hrd, tier),
113
+ drain: 2 * Math.max(250_000, 1_250_000 - con * 1250),
114
+ depth: gathererDepthForTier(tol, tier),
87
115
  }
88
116
  }
89
117
 
@@ -111,7 +139,20 @@ export function computeCrafterCapabilities(stats: Record<string, number>): {
111
139
 
112
140
  return {
113
141
  speed: 100 + Math.floor((fin * 4) / 5),
114
- drain: Math.max(5, 30 - Math.floor(con / 33)),
142
+ drain: Math.max(5000, 30000 - Math.floor((con * 1000) / 33)),
143
+ }
144
+ }
145
+
146
+ export function computeBuilderCapabilities(stats: Record<string, number>): {
147
+ speed: number
148
+ drain: number
149
+ } {
150
+ const coh = stats.cohesion
151
+ const tol = stats.tolerance
152
+
153
+ return {
154
+ speed: 100 + Math.floor((coh * 4) / 5),
155
+ drain: Math.max(5000, 30000 - Math.floor((tol * 1000) / 33)),
115
156
  }
116
157
  }
117
158
 
@@ -130,7 +171,7 @@ export function computeHaulerCapabilities(
130
171
  return {
131
172
  capacity: computeHaulerCapacity(resonance, tier),
132
173
  efficiency: 2000 + plasticity * 6,
133
- drain: Math.max(3, 15 - Math.floor(conductivity / 80)),
174
+ drain: computeHaulerDrain(conductivity, tier),
134
175
  }
135
176
  }
136
177
 
@@ -145,16 +186,21 @@ export function computeLauncherCapabilities(
145
186
  }
146
187
  }
147
188
 
148
- export function computeStorageCapabilities(stats: Record<string, number>): {
189
+ export function computeStorageCapabilities(
190
+ stats: Record<string, number>,
191
+ tier: number
192
+ ): {
149
193
  capacity: number
194
+ drain: number
150
195
  } {
151
- const strength = stats.strength ?? 0
152
- const density = stats.density ?? 0
153
- const hardness = stats.hardness ?? 0
154
- const cohesion = stats.cohesion ?? 0
155
-
156
- const statSum = strength + density + hardness + cohesion
157
- return {capacity: 10_000_000 + Math.floor((statSum * 50_000_000) / 3996)}
196
+ return {
197
+ capacity: computeCargoBayCapacity(
198
+ stats.strength ?? 0,
199
+ stats.density ?? 0,
200
+ stats.hardness ?? 0
201
+ ),
202
+ drain: computeCargoBayDrain(stats.cohesion ?? 0, tier),
203
+ }
158
204
  }
159
205
 
160
206
  export function computeBatteryCapabilities(stats: Record<string, number>): {
@@ -166,7 +212,7 @@ export function computeBatteryCapabilities(stats: Record<string, number>): {
166
212
  const insulation = stats.insulation ?? 0
167
213
 
168
214
  const statSum = volatility + thermal + plasticity + insulation
169
- return {capacity: 2_500 + Math.floor((statSum * 7_500) / 3996)}
215
+ return {capacity: 2_500_000 + Math.floor((statSum * 7_500_000) / 3996)}
170
216
  }
171
217
 
172
218
  import {
@@ -174,10 +220,8 @@ import {
174
220
  ITEM_CONTAINER_T2_PACKED,
175
221
  ITEM_EXTRACTOR_T1_PACKED,
176
222
  ITEM_FACTORY_T1_PACKED,
177
- ITEM_HAULER_SHIP_T2_PACKED,
178
223
  ITEM_MASS_CATCHER_T1_PACKED,
179
224
  ITEM_MASS_DRIVER_T1_PACKED,
180
- ITEM_PROSPECTOR_T2_PACKED,
181
225
  ITEM_SHIP_T1_PACKED,
182
226
  ITEM_WAREHOUSE_T1_PACKED,
183
227
  } from '../data/item-ids'
@@ -190,6 +234,7 @@ import {
190
234
  MODULE_LOADER,
191
235
  MODULE_STORAGE,
192
236
  MODULE_CRAFTER,
237
+ MODULE_BUILDER,
193
238
  MODULE_HAULER,
194
239
  MODULE_WARP,
195
240
  MODULE_LAUNCHER,
@@ -205,7 +250,13 @@ import {
205
250
  type InstalledModule,
206
251
  } from '../entities/slot-multiplier'
207
252
  import type {EntitySlot} from '../data/recipes-runtime'
208
- import {computeHaulerCapacity, computeTravelDrain} from '../nft/description'
253
+ import {
254
+ computeCargoBayCapacity,
255
+ computeCargoBayDrain,
256
+ computeHaulerCapacity,
257
+ computeHaulerDrain,
258
+ computeTravelDrain,
259
+ } from '../nft/description'
209
260
 
210
261
  export const CAPACITY_TIER_TABLE = [1.0, 1.4, 1.8, 2.2, 2.6, 3.0, 3.4, 3.8, 4.2, 4.6]
211
262
 
@@ -220,25 +271,24 @@ export function applyCapacityTier(baseCapacity: number, tier: number): number {
220
271
 
221
272
  export function computeBaseCapacity(itemId: number, stats: Record<string, number>): number {
222
273
  let base: number
223
- switch (itemId) {
224
- case ITEM_SHIP_T1_PACKED:
225
- case ITEM_PROSPECTOR_T2_PACKED:
226
- case ITEM_HAULER_SHIP_T2_PACKED:
227
- base = computeShipHullCapabilities(stats).capacity
228
- break
229
- case ITEM_EXTRACTOR_T1_PACKED:
230
- case ITEM_FACTORY_T1_PACKED:
231
- case ITEM_MASS_DRIVER_T1_PACKED:
232
- case ITEM_MASS_CATCHER_T1_PACKED:
233
- case ITEM_CONTAINER_T1_PACKED:
234
- case ITEM_CONTAINER_T2_PACKED:
235
- base = computeContainerCapabilities(stats).capacity
236
- break
237
- case ITEM_WAREHOUSE_T1_PACKED:
238
- base = computeWarehouseHullCapabilities(stats).capacity
239
- break
240
- default:
241
- return 0
274
+ if (isShipHull(itemId)) {
275
+ base = computeShipHullCapabilities(stats, itemId).capacity
276
+ } else {
277
+ switch (itemId) {
278
+ case ITEM_EXTRACTOR_T1_PACKED:
279
+ case ITEM_FACTORY_T1_PACKED:
280
+ case ITEM_MASS_DRIVER_T1_PACKED:
281
+ case ITEM_MASS_CATCHER_T1_PACKED:
282
+ case ITEM_CONTAINER_T1_PACKED:
283
+ case ITEM_CONTAINER_T2_PACKED:
284
+ base = computeContainerCapabilities(stats).capacity
285
+ break
286
+ case ITEM_WAREHOUSE_T1_PACKED:
287
+ base = computeWarehouseHullCapabilities(stats).capacity
288
+ break
289
+ default:
290
+ return 0
291
+ }
242
292
  }
243
293
  return applyCapacityTier(base, getItem(itemId).tier)
244
294
  }
@@ -257,7 +307,7 @@ export function computeWarehouseHullCapabilities(stats: Record<string, number>):
257
307
  const statSum = (stats.strength ?? 0) + (stats.hardness ?? 0)
258
308
  const exponent = statSum / 1998.0
259
309
  return {
260
- hullmass: computeBaseHullmass(stats),
310
+ hullmass: computeBaseHullmass(ITEM_WAREHOUSE_T1_PACKED, stats),
261
311
  capacity: Math.floor(100000000 * 6 ** exponent),
262
312
  }
263
313
  }
@@ -277,6 +327,13 @@ export interface CrafterLaneEntry {
277
327
  outputPct: number
278
328
  }
279
329
 
330
+ export interface BuilderLaneEntry {
331
+ slotIndex: number
332
+ speed: number
333
+ drain: number
334
+ outputPct: number
335
+ }
336
+
280
337
  export interface LoaderLaneEntry {
281
338
  slotIndex: number
282
339
  mass: number
@@ -284,10 +341,18 @@ export interface LoaderLaneEntry {
284
341
  outputPct: number
285
342
  }
286
343
 
344
+ export interface TravelDrainBreakdown {
345
+ engine: number
346
+ cargoHolds: number
347
+ tractorBeams: number
348
+ total: number
349
+ }
350
+
287
351
  export interface ComputedCapabilities {
288
352
  hullmass: number
289
353
  capacity: number
290
354
  engines?: {thrust: number; drain: number}
355
+ travelDrain?: TravelDrainBreakdown
291
356
  generator?: {capacity: number; recharge: number}
292
357
  gatherer?: {yield: number; drain: number; depth: number}
293
358
  gathererLanes?: GathererLaneEntry[]
@@ -295,6 +360,8 @@ export interface ComputedCapabilities {
295
360
  loaderLanes?: LoaderLaneEntry[]
296
361
  crafter?: {speed: number; drain: number}
297
362
  crafterLanes?: CrafterLaneEntry[]
363
+ builder?: {speed: number; drain: number}
364
+ builderLanes?: BuilderLaneEntry[]
298
365
  hauler?: {
299
366
  capacity: number
300
367
  efficiency: number
@@ -331,6 +398,7 @@ export function computeEntityCapabilities(
331
398
  let hasGatherer = false
332
399
 
333
400
  let totalStorageCapacity = 0
401
+ let totalCargoHoldDrain = 0
334
402
  const baseCapacity = computeBaseCapacity(itemId, stats)
335
403
  let installedModuleMass = 0
336
404
 
@@ -338,6 +406,10 @@ export function computeEntityCapabilities(
338
406
  let totalCrafterDrain = 0
339
407
  let hasCrafter = false
340
408
 
409
+ let totalBuilderSpeed = 0
410
+ let totalBuilderDrain = 0
411
+ let hasBuilder = false
412
+
341
413
  let totalHaulerCapacity = 0
342
414
  let weightedHaulerEffNum = 0n
343
415
  let totalHaulerDrain = 0
@@ -356,6 +428,7 @@ export function computeEntityCapabilities(
356
428
 
357
429
  const gathererLanes: GathererLaneEntry[] = []
358
430
  const crafterLanes: CrafterLaneEntry[] = []
431
+ const builderLanes: BuilderLaneEntry[] = []
359
432
  const loaderLanes: LoaderLaneEntry[] = []
360
433
 
361
434
  for (const mod of modules) {
@@ -369,17 +442,24 @@ export function computeEntityCapabilities(
369
442
  hasEngine = true
370
443
  const caps = computeEngineCapabilities(decodedStats)
371
444
  totalThrust += applySlotMultiplier(caps.thrust, amp)
372
- totalEngineThm += decodedStats.thermal ?? 0
445
+ totalEngineThm += computeEffectiveModuleStat(decodedStats.thermal ?? 0)
373
446
  engineCount += 1
374
447
  } else if (modType === MODULE_GENERATOR) {
375
448
  hasGenerator = true
376
449
  const caps = computeGeneratorCapabilities(decodedStats)
377
- totalGenCapacity += applySlotMultiplier(caps.capacity, amp)
378
- totalGenRecharge += applySlotMultiplier(caps.recharge, amp)
450
+ totalGenCapacity += applySlotMultiplierUint32(caps.capacity, amp)
451
+ totalGenRecharge += applySlotMultiplierUint32(caps.recharge, amp)
379
452
  } else if (modType === MODULE_GATHERER) {
380
453
  hasGatherer = true
381
454
  const tier = item.tier
382
- const caps = computeGathererCapabilities(decodedStats, tier)
455
+ const caps = computeGathererCapabilities(
456
+ {
457
+ strength: decodeStat(mod.stats, 0),
458
+ tolerance: decodeStat(mod.stats, 1),
459
+ saturation: decodeStat(mod.stats, 2),
460
+ },
461
+ tier
462
+ )
383
463
  const scaledYield = applySlotMultiplier(caps.yield, amp)
384
464
  totalGathYield += scaledYield
385
465
  totalGathDrain += caps.drain
@@ -404,8 +484,9 @@ export function computeEntityCapabilities(
404
484
  outputPct: amp,
405
485
  })
406
486
  } else if (modType === MODULE_STORAGE) {
407
- const caps = computeStorageCapabilities(decodedStats)
487
+ const caps = computeStorageCapabilities(decodedStats, item.tier)
408
488
  totalStorageCapacity += applySlotMultiplierUint32(caps.capacity, amp)
489
+ totalCargoHoldDrain += caps.drain
409
490
  } else if (modType === MODULE_CRAFTER) {
410
491
  hasCrafter = true
411
492
  const caps = computeCrafterCapabilities(decodedStats)
@@ -418,6 +499,18 @@ export function computeEntityCapabilities(
418
499
  drain: caps.drain,
419
500
  outputPct: amp,
420
501
  })
502
+ } else if (modType === MODULE_BUILDER) {
503
+ hasBuilder = true
504
+ const caps = computeBuilderCapabilities(decodedStats)
505
+ const scaledSpeed = applySlotMultiplier(caps.speed, amp)
506
+ totalBuilderSpeed += scaledSpeed
507
+ totalBuilderDrain += caps.drain
508
+ builderLanes.push({
509
+ slotIndex: mod.slotIndex,
510
+ speed: scaledSpeed,
511
+ drain: caps.drain,
512
+ outputPct: amp,
513
+ })
421
514
  } else if (modType === MODULE_HAULER) {
422
515
  hasHauler = true
423
516
  const caps = computeHaulerCapabilities(decodedStats, item.tier)
@@ -454,13 +547,21 @@ export function computeEntityCapabilities(
454
547
  }
455
548
 
456
549
  const result: ComputedCapabilities = {
457
- hullmass: computeBaseHullmass(stats) + installedModuleMass,
550
+ hullmass: computeBaseHullmass(itemId, stats) + installedModuleMass,
458
551
  capacity: clampUint32(baseCapacity + totalStorageCapacity),
459
552
  }
460
553
 
461
554
  if (hasEngine) {
462
555
  const avgThm = engineCount > 0 ? Math.trunc(totalEngineThm / engineCount) : 0
463
- result.engines = {thrust: totalThrust, drain: computeTravelDrain(totalThrust, avgThm)}
556
+ const engineDrain = computeTravelDrain(totalThrust, avgThm)
557
+ const totalDrain = clampUint32(engineDrain + totalCargoHoldDrain + totalHaulerDrain)
558
+ result.engines = {thrust: totalThrust, drain: totalDrain}
559
+ result.travelDrain = {
560
+ engine: engineDrain,
561
+ cargoHolds: totalCargoHoldDrain,
562
+ tractorBeams: totalHaulerDrain,
563
+ total: totalDrain,
564
+ }
464
565
  }
465
566
  if (hasGenerator) {
466
567
  result.generator = {
@@ -488,6 +589,10 @@ export function computeEntityCapabilities(
488
589
  result.crafter = {speed: clampUint16(totalCrafterSpeed), drain: totalCrafterDrain}
489
590
  result.crafterLanes = crafterLanes
490
591
  }
592
+ if (hasBuilder) {
593
+ result.builder = {speed: clampUint16(totalBuilderSpeed), drain: totalBuilderDrain}
594
+ result.builderLanes = builderLanes
595
+ }
491
596
  if (hasHauler) {
492
597
  const efficiency =
493
598
  totalHaulerCapacity > 0 ? Number(weightedHaulerEffNum / BigInt(totalHaulerCapacity)) : 0
@@ -521,7 +626,7 @@ export function computeContainerCapabilities(stats: Record<string, number>): {
521
626
  const statSum = (stats.strength ?? 0) + (stats.hardness ?? 0)
522
627
  const exponent = statSum / 1998.0
523
628
  return {
524
- hullmass: computeBaseHullmass(stats),
629
+ hullmass: computeBaseHullmass(ITEM_CONTAINER_T1_PACKED, stats),
525
630
  capacity: Math.floor(22000000 * 6 ** exponent),
526
631
  }
527
632
  }
@@ -4,6 +4,7 @@ import {getRecipe, type Recipe} from '../data/recipes-runtime'
4
4
  import {getItem} from '../data/catalog'
5
5
  import {getStatDefinitions} from './stats'
6
6
  import {deriveResourceStats} from './stratum'
7
+ import {ITEM_SHIP_T1_PACKED} from '../data/item-ids'
7
8
 
8
9
  export interface StackInput {
9
10
  quantity: number
@@ -41,6 +42,7 @@ function getItemStatKeys(itemId: number): string[] {
41
42
  if (!item.category) return []
42
43
  return getStatDefinitions(item.category).map((d) => d.key)
43
44
  }
45
+ if (itemId === ITEM_SHIP_T1_PACKED) return ['strength', 'density', '', '']
44
46
  const recipe = getRecipe(itemId)
45
47
  if (!recipe) return []
46
48
  return recipe.statSlots.map((slot) => keyForStatSlot(recipe, slot))
@@ -7,7 +7,6 @@ import {
7
7
  } from './recipe-usage'
8
8
  import {
9
9
  ITEM_SENSOR,
10
- ITEM_SENSOR_T2,
11
10
  ITEM_RESIN,
12
11
  ITEM_FRAME,
13
12
  ITEM_PLATE,
@@ -15,7 +14,13 @@ import {
15
14
  ITEM_GATHERER_T1,
16
15
  ITEM_CRAFTER_T1,
17
16
  ITEM_EXTRACTOR_T1_PACKED,
18
- ITEM_SHIP_T1_PACKED,
17
+ ITEM_ROUSTABOUT_T1_PACKED,
18
+ ITEM_PROSPECTOR_T1_PACKED,
19
+ ITEM_TENDER_T1_PACKED,
20
+ ITEM_TUG_T1_PACKED,
21
+ ITEM_PORTER_T1_PACKED,
22
+ ITEM_WRANGLER_T1_PACKED,
23
+ ITEM_DREDGER_T1_PACKED,
19
24
  } from '../data/item-ids'
20
25
 
21
26
  test('getAllRecipes returns the full catalog including the gatherer', () => {
@@ -28,9 +33,7 @@ test('getRecipeConsumers lists every recipe that consumes Sensor', () => {
28
33
  const consumers = getRecipeConsumers(ITEM_SENSOR)
29
34
  const ids = consumers.map((c) => c.outputItemId).sort((a, b) => a - b)
30
35
  expect(ids).toEqual(
31
- [ITEM_CRAFTER_T1, ITEM_SHIP_T1_PACKED, ITEM_EXTRACTOR_T1_PACKED, ITEM_SENSOR_T2].sort(
32
- (a, b) => a - b
33
- )
36
+ [ITEM_CRAFTER_T1, ITEM_EXTRACTOR_T1_PACKED, ITEM_ROUSTABOUT_T1_PACKED].sort((a, b) => a - b)
34
37
  )
35
38
  })
36
39
 
@@ -73,9 +76,9 @@ test('getResourceDemand scales by quantity', () => {
73
76
  expect(getResourceDemand(ITEM_PLATE, 3)).toEqual({ore: 30})
74
77
  })
75
78
 
76
- test('getComponentDemand reports Resin as consumed by three recipes', () => {
79
+ test('getComponentDemand reports Resin as consumed by nine recipes', () => {
77
80
  const demand = getComponentDemand()
78
81
  const resin = demand.find((d) => d.itemId === ITEM_RESIN)
79
82
  expect(resin).toBeDefined()
80
- expect(resin?.consumerCount).toBe(3)
83
+ expect(resin?.consumerCount).toBe(9)
81
84
  })
@@ -37,6 +37,22 @@ export function rollupCrafter(
37
37
  }
38
38
  }
39
39
 
40
+ export function rollupBuilder(
41
+ lanes: ServerContract.Types.builder_lane[]
42
+ ): {speed: UInt16; drain: UInt32} | undefined {
43
+ if (lanes.length === 0) return undefined
44
+ let totalSpeed = 0
45
+ let totalDrain = 0
46
+ for (const l of lanes) {
47
+ totalSpeed += Number(l.speed)
48
+ totalDrain += Number(l.drain)
49
+ }
50
+ return {
51
+ speed: UInt16.from(Math.min(totalSpeed, 65535)),
52
+ drain: UInt32.from(totalDrain),
53
+ }
54
+ }
55
+
40
56
  export function rollupLoaders(
41
57
  lanes: ServerContract.Types.loader_lane[]
42
58
  ): {mass: UInt32; thrust: UInt16; quantity: UInt8} | undefined {
@@ -0,0 +1,12 @@
1
+ export const MODULE_STAT_SCALING_ANCHOR = 213
2
+ export const MODULE_STAT_SCALING_POST_ANCHOR_PERCENT = 40
3
+
4
+ export function computeEffectiveModuleStat(stat: number): number {
5
+ if (stat <= MODULE_STAT_SCALING_ANCHOR) return stat
6
+ return (
7
+ MODULE_STAT_SCALING_ANCHOR +
8
+ Math.floor(
9
+ ((stat - MODULE_STAT_SCALING_ANCHOR) * MODULE_STAT_SCALING_POST_ANCHOR_PERCENT) / 100
10
+ )
11
+ )
12
+ }
@@ -112,7 +112,7 @@ export function makeEntity(packedItemId: number, state: EntityStateInput): Entit
112
112
  holds: [],
113
113
  }
114
114
 
115
- if (state.energy !== undefined) info.energy = UInt16.from(state.energy)
115
+ if (state.energy !== undefined) info.energy = UInt32.from(state.energy)
116
116
 
117
117
  if (kind === 'container') {
118
118
  info.modules = []
@@ -174,6 +174,14 @@ export function makeEntity(packedItemId: number, state: EntityStateInput): Entit
174
174
  output_pct: l.outputPct,
175
175
  })
176
176
  )
177
+ info.builder_lanes = (caps.builderLanes ?? []).map((l) =>
178
+ ServerContract.Types.builder_lane.from({
179
+ slot_index: l.slotIndex,
180
+ speed: l.speed,
181
+ drain: l.drain,
182
+ output_pct: l.outputPct,
183
+ })
184
+ )
177
185
  info.loader_lanes = (caps.loaderLanes ?? []).map((l) =>
178
186
  ServerContract.Types.loader_lane.from({
179
187
  slot_index: l.slotIndex,
@@ -186,6 +194,7 @@ export function makeEntity(packedItemId: number, state: EntityStateInput): Entit
186
194
 
187
195
  if (!info.gatherer_lanes) info.gatherer_lanes = []
188
196
  if (!info.crafter_lanes) info.crafter_lanes = []
197
+ if (!info.builder_lanes) info.builder_lanes = []
189
198
  if (!info.loader_lanes) info.loader_lanes = []
190
199
 
191
200
  const entityInfo = ServerContract.Types.entity_info.from(info)