@shipload/sdk 1.0.0-next.48 → 1.0.0-next.49

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.
@@ -1,9 +1,14 @@
1
1
  import {UInt8, UInt16, UInt32, UInt64} from '@wharfkit/antelope'
2
2
  import {expect, test, describe} from 'bun:test'
3
- import type {GathererStats} from '../types/capabilities'
4
3
  import {ServerContract} from '../contracts'
5
- import {calc_gather_duration} from '../capabilities/gathering'
6
- import {planParallelGather, planParallelTransfer, type GatherPlanEntity} from './index'
4
+ import {
5
+ planParallelTransfer,
6
+ type GatherPlanEntity,
7
+ gatherEnergyCost,
8
+ splitCost,
9
+ maxQtyForCharge,
10
+ buildGatherPlan,
11
+ } from './index'
7
12
 
8
13
  function gathererLane(
9
14
  slotIndex: number,
@@ -63,257 +68,258 @@ function entity(overrides: EntityOverrides = {}): GatherPlanEntity {
63
68
 
64
69
  const NOW = new Date('2026-06-21T00:00:00.000Z')
65
70
 
66
- describe('planParallelGather', () => {
67
- test('sanity: single-gatherer qty 20 = ~35s matches calc_gather_duration', () => {
68
- const gatherer: GathererStats = {
69
- yield: UInt16.from(57),
70
- drain: UInt32.from(500),
71
- depth: UInt16.from(5000),
72
- }
73
- const dur = calc_gather_duration(gatherer, 228, 20, 0, 1000)
74
- expect(Number(dur)).toBeCloseTo(35, 0)
75
- })
76
-
77
- test('two gatherers: quantities proportional to yield, durations within 1s', () => {
78
- const YIELD1 = 200
79
- const YIELD2 = 400
80
- const DEPTH = 5000
81
- const DRAIN = 500
82
- const QUANTITY = 60
83
- const STRATUM = 0
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
84
76
 
85
77
  const e = entity({
86
- gatherer_lanes: [
87
- gathererLane(0, YIELD1, DRAIN, DEPTH),
88
- gathererLane(1, YIELD2, DRAIN, DEPTH),
89
- ],
90
- generator: energyStats(10000, 100),
91
- energy: 10000,
78
+ loader_lanes: [loaderLane(0, 500, THRUST1), loaderLane(1, 500, THRUST2)],
92
79
  })
93
80
 
94
- const plan = planParallelGather(e, {quantity: QUANTITY}, STRATUM, NOW)
81
+ const plan = planParallelTransfer(e, {quantity: QUANTITY})
95
82
 
96
83
  expect(plan).toHaveLength(2)
97
84
  expect(plan.reduce((s, p) => s + p.quantity, 0)).toBe(QUANTITY)
98
85
 
99
86
  const q1 = plan.find((p) => p.slot === 0)!.quantity
100
87
  const q2 = plan.find((p) => p.slot === 1)!.quantity
101
- expect(q1 + q2).toBe(QUANTITY)
102
- expect(q2 / q1).toBeCloseTo(YIELD2 / YIELD1, 0)
103
-
104
- const ITEM_MASS = 228
105
- const RICHNESS = 1000
106
- const g1: GathererStats = {
107
- yield: UInt16.from(YIELD1),
108
- drain: UInt32.from(DRAIN),
109
- depth: UInt16.from(DEPTH),
110
- }
111
- const g2: GathererStats = {
112
- yield: UInt16.from(YIELD2),
113
- drain: UInt32.from(DRAIN),
114
- depth: UInt16.from(DEPTH),
115
- }
116
- const dur1 = Number(calc_gather_duration(g1, ITEM_MASS, q1, STRATUM, RICHNESS))
117
- const dur2 = Number(calc_gather_duration(g2, ITEM_MASS, q2, STRATUM, RICHNESS))
118
- expect(Math.abs(dur1 - dur2)).toBeLessThan(1)
88
+ expect(q2 / q1).toBeCloseTo(THRUST2 / THRUST1, 0)
119
89
  })
120
90
 
121
- test("'max' target: uses all reaching lanes, each slot gets >= 1 unit", () => {
122
- const e = entity({
123
- gatherer_lanes: [gathererLane(0, 200, 500, 5000), gathererLane(1, 300, 500, 5000)],
124
- generator: energyStats(10000, 100),
125
- energy: 10000,
126
- })
91
+ test('no loader lanes: returns empty plan', () => {
92
+ const plan = planParallelTransfer(entity({loader_lanes: []}), {quantity: 10})
93
+ expect(plan).toHaveLength(0)
94
+ })
127
95
 
128
- const plan = planParallelGather(e, 'max', 0, NOW)
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
+ })
129
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')
130
108
  expect(plan.length).toBeGreaterThan(0)
131
109
  for (const entry of plan) {
132
110
  expect(entry.quantity).toBeGreaterThanOrEqual(1)
133
111
  }
134
112
  })
113
+ })
135
114
 
136
- test('energy-starved: drops the lowest-yield lane(s) until the pool sustains the plan', () => {
137
- // Full-Q energy: 3 lanes=>144, 2 lanes(drop yield=50)=>120; pool 130 fits 2 but not 3.
138
- const e = entity({
139
- gatherer_lanes: [
140
- gathererLane(0, 50, 10000, 5000),
141
- gathererLane(1, 100, 10000, 5000),
142
- gathererLane(2, 100, 10000, 5000),
143
- ],
144
- generator: energyStats(10000, 1),
145
- energy: 130,
146
- })
147
-
148
- const plan = planParallelGather(e, {quantity: 120}, 0, NOW)
115
+ describe('cost helpers', () => {
116
+ const STRATUM = 100
117
+ const ITEM_MASS = 1000
118
+ const RICHNESS = 500
149
119
 
150
- // Lowest-yield lane (slot 0) dropped; the two yield-100 lanes survive.
151
- expect(plan).toHaveLength(2)
152
- expect(plan.find((p) => p.slot === 0)).toBeUndefined()
153
- expect(plan.reduce((s, p) => s + p.quantity, 0)).toBe(120)
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)
154
124
  })
155
125
 
156
- test('energy-starved: single lane caps quantity to the sustainable max', () => {
157
- // energyPerUnit=1, full Q=120 costs 120 > pool 50, so quantity caps at 50.
158
- const e = entity({
159
- gatherer_lanes: [gathererLane(0, 100, 10000, 5000)],
160
- generator: energyStats(10000, 1),
161
- energy: 50,
162
- })
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
+ })
163
132
 
164
- const plan = planParallelGather(e, {quantity: 120}, 0, NOW)
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
+ })
165
138
 
166
- expect(plan).toHaveLength(1)
167
- expect(plan[0].slot).toBe(0)
168
- expect(plan[0].quantity).toBe(50)
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)
169
145
  })
146
+ })
170
147
 
171
- test('energy-starved: projected energy nets out a queued gather task', () => {
172
- // A queued task costing 9970 leaves 30 projected energy => quantity caps at 30.
173
- const queued = ServerContract.Types.lane.from({
174
- lane_key: UInt8.from(0),
175
- schedule: {
176
- started: NOW.toISOString().slice(0, -1),
177
- tasks: [
178
- ServerContract.Types.task.from({
179
- type: UInt8.from(5),
180
- duration: UInt32.from(100),
181
- cancelable: 0,
182
- cargo: [],
183
- entitytarget: {entity_type: 'ship', entity_id: UInt64.from(1)},
184
- energy_cost: UInt32.from(9970),
185
- }),
186
- ],
187
- },
188
- })
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
+ }
189
157
 
158
+ test('single cycle when the target fits one charge and energy is full', () => {
190
159
  const e = entity({
191
- gatherer_lanes: [gathererLane(0, 100, 10000, 5000)],
192
- generator: energyStats(10000, 1),
193
- energy: 10000,
194
- lanes: [queued],
160
+ gatherer_lanes: [gathererLane(0, 700, 1250, 5000)],
161
+ generator: energyStats(1200, 2),
162
+ energy: 1200,
195
163
  })
196
-
197
- const plan = planParallelGather(e, {quantity: 120}, 0, NOW)
198
-
199
- expect(plan).toHaveLength(1)
200
- expect(plan[0].quantity).toBe(30)
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')
201
171
  })
202
172
 
203
- test('stratum filter: shallow lane excluded, only deep lane used', () => {
173
+ test('two limpets split one charge proportional to yield, finish within 1s of each other', () => {
204
174
  const e = entity({
205
- gatherer_lanes: [gathererLane(0, 200, 500, 500), gathererLane(1, 300, 500, 5000)],
206
- generator: energyStats(10000, 100),
207
- energy: 10000,
175
+ gatherer_lanes: [gathererLane(0, 200, 500, 5000), gathererLane(1, 400, 500, 5000)],
176
+ generator: energyStats(60_000, 2),
177
+ energy: 60_000,
208
178
  })
209
-
210
- const plan = planParallelGather(e, {quantity: 10}, 2000, NOW)
211
-
212
- expect(plan).toHaveLength(1)
213
- expect(plan[0].slot).toBe(1)
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)
214
185
  })
215
186
 
216
- test('no reaching gatherers throws', () => {
187
+ test('multi-cycle: fills beyond one charge; totalOre equals fill target; each cycle fits the charge', () => {
188
+ const CAP = 40
217
189
  const e = entity({
218
- gatherer_lanes: [gathererLane(0, 200, 500, 100)],
219
- generator: energyStats(10000, 100),
220
- energy: 10000,
190
+ gatherer_lanes: [gathererLane(0, 700, 1250, 5000)],
191
+ generator: energyStats(CAP, 2),
192
+ energy: CAP,
221
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
+ })
222
214
 
223
- expect(() => planParallelGather(e, {quantity: 10}, 2000, NOW)).toThrow(
224
- 'no gatherer reaches this stratum'
225
- )
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')
226
228
  })
227
229
 
228
- test('two identical gatherers: per-lane quantity halved, durations equal and within 1s', () => {
229
- const YIELD = 200
230
- const DEPTH = 5000
231
- const DRAIN = 500
232
- const QUANTITY = 60
233
- const STRATUM = 0
234
-
235
- const eSingle = entity({
236
- gatherer_lanes: [gathererLane(0, YIELD, DRAIN, DEPTH)],
237
- generator: energyStats(10000, 100),
238
- energy: 10000,
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,
239
236
  })
240
- const eDouble = entity({
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({
241
244
  gatherer_lanes: [
242
- gathererLane(0, YIELD, DRAIN, DEPTH),
243
- gathererLane(1, YIELD, DRAIN, DEPTH),
245
+ gathererLane(0, 700, 1250, 5000), // reaches 100
246
+ gathererLane(1, 700, 1250, 50), // does not reach 100
244
247
  ],
245
- generator: energyStats(10000, 100),
246
- energy: 10000,
248
+ generator: energyStats(60_000, 2),
249
+ energy: 60_000,
247
250
  })
248
-
249
- const planSingle = planParallelGather(eSingle, {quantity: QUANTITY}, STRATUM, NOW)
250
- const planDouble = planParallelGather(eDouble, {quantity: QUANTITY}, STRATUM, NOW)
251
-
252
- expect(planSingle).toHaveLength(1)
253
- expect(planDouble).toHaveLength(2)
254
-
255
- const singleQ = planSingle[0].quantity
256
- const doubleQ1 = planDouble[0].quantity
257
- const doubleQ2 = planDouble[1].quantity
258
- expect(doubleQ1 + doubleQ2).toBe(QUANTITY)
259
-
260
- const ITEM_MASS = 228
261
- const RICHNESS = 1000
262
- const g: GathererStats = {
263
- yield: UInt16.from(YIELD),
264
- drain: UInt32.from(DRAIN),
265
- depth: UInt16.from(DEPTH),
266
- }
267
- const durSingle = Number(calc_gather_duration(g, ITEM_MASS, singleQ, STRATUM, RICHNESS))
268
- const durDouble1 = Number(calc_gather_duration(g, ITEM_MASS, doubleQ1, STRATUM, RICHNESS))
269
- const durDouble2 = Number(calc_gather_duration(g, ITEM_MASS, doubleQ2, STRATUM, RICHNESS))
270
-
271
- expect(durDouble1).toBeCloseTo(durSingle / 2, 0)
272
- expect(durDouble2).toBeCloseTo(durSingle / 2, 0)
273
- expect(Math.abs(durDouble1 - durDouble2)).toBeLessThan(1)
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)
274
255
  })
275
- })
276
-
277
- describe('planParallelTransfer', () => {
278
- test('two loader lanes: quantities proportional to thrust, sums to target', () => {
279
- const THRUST1 = 100
280
- const THRUST2 = 200
281
- const QUANTITY = 90
282
256
 
257
+ test('reports structured limpet reach counts (all reaching)', () => {
283
258
  const e = entity({
284
- loader_lanes: [loaderLane(0, 500, THRUST1), loaderLane(1, 500, THRUST2)],
259
+ gatherer_lanes: [gathererLane(0, 700, 1250, 5000), gathererLane(1, 700, 1250, 5000)],
260
+ generator: energyStats(60_000, 2),
261
+ energy: 60_000,
285
262
  })
263
+ const plan = buildGatherPlan(e, STRATUM, {quantity: 50}, OPTS)
264
+ expect(plan.totalLimpets).toBe(2)
265
+ expect(plan.reachingCount).toBe(2)
266
+ })
286
267
 
287
- const plan = planParallelTransfer(e, {quantity: QUANTITY})
288
-
289
- expect(plan).toHaveLength(2)
290
- expect(plan.reduce((s, p) => s + p.quantity, 0)).toBe(QUANTITY)
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
+ })
291
281
 
292
- const q1 = plan.find((p) => p.slot === 0)!.quantity
293
- const q2 = plan.find((p) => p.slot === 1)!.quantity
294
- expect(q2 / q1).toBeCloseTo(THRUST2 / THRUST1, 0)
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)
295
292
  })
296
293
 
297
- test('no loader lanes: returns empty plan', () => {
298
- const plan = planParallelTransfer(entity({loader_lanes: []}), {quantity: 10})
299
- expect(plan).toHaveLength(0)
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')
300
301
  })
301
302
 
302
- test('thrust=0 loader lane (no-loader/mobility case): returns empty plan', () => {
303
- const plan = planParallelTransfer(entity({loader_lanes: [loaderLane(0, 500, 0)]}), {
304
- quantity: 10,
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,
305
308
  })
306
- expect(plan).toHaveLength(0)
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')
307
313
  })
308
314
 
309
- test("'max' target: each loader lane gets >= 1 unit", () => {
315
+ test('first cycle skips recharge when current energy already covers the small batch', () => {
310
316
  const e = entity({
311
- loader_lanes: [loaderLane(0, 500, 100), loaderLane(1, 500, 200)],
317
+ gatherer_lanes: [gathererLane(0, 700, 1250, 5000)],
318
+ generator: energyStats(60_000, 2),
319
+ energy: 60_000, // full
312
320
  })
313
- const plan = planParallelTransfer(e, 'max')
314
- expect(plan.length).toBeGreaterThan(0)
315
- for (const entry of plan) {
316
- expect(entry.quantity).toBeGreaterThanOrEqual(1)
317
- }
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)
318
324
  })
319
325
  })
@@ -39,6 +39,32 @@ describe('unwrap duration mirror', () => {
39
39
  expect(unwrapTransitDuration(0, {x: 0, y: 0}, {x: 9, y: 9})).toBe(0)
40
40
  expect(unwrapLoadDuration(null, 500, 3000)).toBe(0)
41
41
  })
42
+
43
+ test('load altitude is floored so ground-level (z=0) destinations are not instant', () => {
44
+ const loaders = {mass: 500, thrust: 200, quantity: 1}
45
+ const atZero = unwrapLoadDuration(loaders, 1000, 0)
46
+ const at800 = unwrapLoadDuration(loaders, 1000, 800)
47
+ expect(atZero).toBeGreaterThan(0)
48
+ expect(atZero).toBe(at800)
49
+ })
50
+
51
+ test('estimateUnwrapDuration uses worst-loader baseline when dest has no loaders', () => {
52
+ // IRON (101); same coords so transit is 0 and the estimate is baseline load alone
53
+ const item = {itemId: 101, quantity: 10, modules: [], originX: 0, originY: 0}
54
+ const bareDest = {loader_lanes: [], coordinates: {x: 0, y: 0, z: 800}}
55
+
56
+ const bare = estimateUnwrapDuration(bareDest, item)
57
+ expect(bare).toBeGreaterThan(0)
58
+
59
+ const loadedDest = {
60
+ loader_lanes: [{mass: 500, thrust: 200}],
61
+ coordinates: {x: 0, y: 0, z: 800},
62
+ }
63
+ const loaded = estimateUnwrapDuration(loadedDest, item)
64
+ expect(loaded).toBeLessThanOrEqual(bare)
65
+
66
+ expect(unwrapLoadDuration(null, 500, 3000)).toBe(0)
67
+ })
42
68
  })
43
69
 
44
70
  test('incomingHoldMass sums incoming-kind hold mass', () => {
@@ -7,6 +7,9 @@ import {taskCargoEffect} from './availability'
7
7
  import {candidateLaneCompletesAt} from './lanes'
8
8
 
9
9
  const NFT_TRANSIT_THRUST = 400
10
+ const BASELINE_LOADER: DerivedLoaders = {mass: 2000, thrust: 1, quantity: 1}
11
+ // ground-level entities (warehouses, z=0) still incur a base orbital climb of load effort
12
+ const MIN_LOAD_Z = 800
10
13
 
11
14
  export interface DerivedLoaders {
12
15
  mass: number
@@ -67,7 +70,7 @@ export function unwrapLoadDuration(
67
70
  ): number {
68
71
  if (!loaders || itemMass <= 0) return 0
69
72
  const total = itemMass + loaders.mass
70
- const flight = flightTime(destZ, acceleration(loaders.thrust, total))
73
+ const flight = flightTime(Math.max(destZ, MIN_LOAD_Z), acceleration(loaders.thrust, total))
71
74
  return Math.floor(flight / loaders.quantity)
72
75
  }
73
76
 
@@ -92,7 +95,7 @@ export function estimateUnwrapDuration(dest: UnwrapDestination, item: UnwrapItem
92
95
  modules: item.modules,
93
96
  })
94
97
  )
95
- const loaders = derivedLoaders(dest.loader_lanes)
98
+ const loaders = derivedLoaders(dest.loader_lanes) ?? BASELINE_LOADER
96
99
  const dz = Number(dest.coordinates.z ?? 0)
97
100
  const load = unwrapLoadDuration(loaders, itemMass, dz)
98
101
  const transit = unwrapTransitDuration(