@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.
- package/lib/shipload.d.ts +113 -71
- package/lib/shipload.js +129 -39
- package/lib/shipload.js.map +1 -1
- package/lib/shipload.m.js +125 -39
- package/lib/shipload.m.js.map +1 -1
- package/package.json +1 -1
- package/src/capabilities/crafting.ts +2 -2
- package/src/index-module.ts +18 -2
- package/src/managers/actions.ts +24 -0
- package/src/managers/flatten-gather-plan.test.ts +80 -0
- package/src/planner/index.ts +169 -54
- package/src/planner/planner.test.ts +201 -195
- package/src/scheduling/unwrap.test.ts +26 -0
- package/src/scheduling/unwrap.ts +5 -2
|
@@ -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 {
|
|
6
|
-
|
|
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('
|
|
67
|
-
test('
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
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 =
|
|
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(
|
|
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(
|
|
122
|
-
const
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
expect(
|
|
153
|
-
expect(
|
|
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('
|
|
157
|
-
|
|
158
|
-
const
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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,
|
|
192
|
-
generator: energyStats(
|
|
193
|
-
energy:
|
|
194
|
-
lanes: [queued],
|
|
160
|
+
gatherer_lanes: [gathererLane(0, 700, 1250, 5000)],
|
|
161
|
+
generator: energyStats(1200, 2),
|
|
162
|
+
energy: 1200,
|
|
195
163
|
})
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
expect(plan).
|
|
200
|
-
expect(plan[0].
|
|
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('
|
|
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,
|
|
206
|
-
generator: energyStats(
|
|
207
|
-
energy:
|
|
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
|
-
|
|
211
|
-
|
|
212
|
-
expect(
|
|
213
|
-
expect(
|
|
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('
|
|
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,
|
|
219
|
-
generator: energyStats(
|
|
220
|
-
energy:
|
|
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
|
-
|
|
224
|
-
|
|
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('
|
|
229
|
-
const
|
|
230
|
-
const
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
|
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,
|
|
243
|
-
gathererLane(1,
|
|
245
|
+
gathererLane(0, 700, 1250, 5000), // reaches 100
|
|
246
|
+
gathererLane(1, 700, 1250, 50), // does not reach 100
|
|
244
247
|
],
|
|
245
|
-
generator: energyStats(
|
|
246
|
-
energy:
|
|
248
|
+
generator: energyStats(60_000, 2),
|
|
249
|
+
energy: 60_000,
|
|
247
250
|
})
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
-
|
|
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
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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
|
-
|
|
293
|
-
const
|
|
294
|
-
|
|
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
|
|
298
|
-
const
|
|
299
|
-
|
|
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('
|
|
303
|
-
const
|
|
304
|
-
|
|
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
|
-
|
|
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(
|
|
315
|
+
test('first cycle skips recharge when current energy already covers the small batch', () => {
|
|
310
316
|
const e = entity({
|
|
311
|
-
|
|
317
|
+
gatherer_lanes: [gathererLane(0, 700, 1250, 5000)],
|
|
318
|
+
generator: energyStats(60_000, 2),
|
|
319
|
+
energy: 60_000, // full
|
|
312
320
|
})
|
|
313
|
-
const plan =
|
|
314
|
-
expect(plan.
|
|
315
|
-
|
|
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', () => {
|
package/src/scheduling/unwrap.ts
CHANGED
|
@@ -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(
|