@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.
- package/lib/shipload.d.ts +289 -80
- package/lib/shipload.js +3099 -2600
- package/lib/shipload.js.map +1 -1
- package/lib/shipload.m.js +3076 -2601
- package/lib/shipload.m.js.map +1 -1
- package/lib/testing.d.ts +66 -20
- package/lib/testing.js +95 -57
- package/lib/testing.js.map +1 -1
- package/lib/testing.m.js +95 -57
- package/lib/testing.m.js.map +1 -1
- package/package.json +1 -1
- package/src/capabilities/crafting.ts +2 -3
- package/src/capabilities/gathering.test.ts +16 -0
- package/src/capabilities/gathering.ts +8 -11
- package/src/contracts/server.ts +45 -29
- package/src/coordinates/address.ts +9 -5
- package/src/coordinates/constants.test.ts +15 -0
- package/src/coordinates/constants.ts +5 -3
- package/src/coordinates/index.ts +11 -0
- package/src/coordinates/memo.test.ts +47 -0
- package/src/coordinates/memo.ts +20 -0
- package/src/data/capability-formulas.ts +0 -1
- package/src/data/entities.json +4 -4
- package/src/data/items.json +5 -5
- package/src/data/recipes.json +39 -65
- package/src/derivation/capabilities.test.ts +133 -0
- package/src/derivation/capabilities.ts +66 -14
- package/src/derivation/rollups.test.ts +55 -0
- package/src/derivation/rollups.ts +56 -0
- package/src/entities/makers.ts +30 -3
- package/src/index-module.ts +30 -2
- package/src/managers/actions.ts +34 -3
- package/src/managers/construction.ts +6 -4
- package/src/managers/context.ts +9 -0
- package/src/managers/coordinates.ts +14 -0
- package/src/managers/plot.ts +2 -4
- package/src/nft/description.ts +25 -6
- package/src/planner/index.ts +127 -0
- package/src/planner/planner.test.ts +319 -0
- package/src/resolution/resolve-item.ts +4 -1
- package/src/scheduling/cancel.test.ts +21 -0
- package/src/scheduling/lanes.test.ts +249 -0
- package/src/scheduling/lanes.ts +140 -2
- package/src/scheduling/projection.ts +73 -16
- package/src/shipload.ts +5 -0
- package/src/testing/projection-parity.ts +26 -2
- package/src/travel/reach.ts +23 -0
- package/src/travel/route-planner.ts +157 -0
- package/src/travel/travel.ts +102 -101
- package/src/types/capabilities.ts +23 -6
- package/src/types/entity.ts +3 -3
- package/src/types.ts +1 -1
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
import {UInt8, UInt16, UInt32, UInt64} from '@wharfkit/antelope'
|
|
2
|
+
import {expect, test, describe} from 'bun:test'
|
|
3
|
+
import type {GathererStats} from '../types/capabilities'
|
|
4
|
+
import {ServerContract} from '../contracts'
|
|
5
|
+
import {calc_gather_duration} from '../capabilities/gathering'
|
|
6
|
+
import {planParallelGather, planParallelTransfer, type GatherPlanEntity} from './index'
|
|
7
|
+
|
|
8
|
+
function gathererLane(
|
|
9
|
+
slotIndex: number,
|
|
10
|
+
yieldVal: number,
|
|
11
|
+
drain: number,
|
|
12
|
+
depth: number
|
|
13
|
+
): ServerContract.Types.gatherer_lane {
|
|
14
|
+
return ServerContract.Types.gatherer_lane.from({
|
|
15
|
+
slot_index: UInt8.from(slotIndex),
|
|
16
|
+
yield: UInt16.from(yieldVal),
|
|
17
|
+
drain: UInt32.from(drain),
|
|
18
|
+
depth: UInt16.from(depth),
|
|
19
|
+
output_pct: UInt16.from(100),
|
|
20
|
+
})
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function loaderLane(
|
|
24
|
+
slotIndex: number,
|
|
25
|
+
mass: number,
|
|
26
|
+
thrust: number
|
|
27
|
+
): ServerContract.Types.loader_lane {
|
|
28
|
+
return ServerContract.Types.loader_lane.from({
|
|
29
|
+
slot_index: UInt8.from(slotIndex),
|
|
30
|
+
mass: UInt32.from(mass),
|
|
31
|
+
thrust: UInt16.from(thrust),
|
|
32
|
+
output_pct: UInt16.from(100),
|
|
33
|
+
})
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function energyStats(capacity: number, recharge: number): ServerContract.Types.energy_stats {
|
|
37
|
+
return ServerContract.Types.energy_stats.from({
|
|
38
|
+
capacity: UInt32.from(capacity),
|
|
39
|
+
recharge: UInt32.from(recharge),
|
|
40
|
+
})
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface EntityOverrides {
|
|
44
|
+
gatherer_lanes?: ServerContract.Types.gatherer_lane[]
|
|
45
|
+
loader_lanes?: ServerContract.Types.loader_lane[]
|
|
46
|
+
generator?: ServerContract.Types.energy_stats
|
|
47
|
+
energy?: number
|
|
48
|
+
lanes?: ServerContract.Types.lane[]
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function entity(overrides: EntityOverrides = {}): GatherPlanEntity {
|
|
52
|
+
return {
|
|
53
|
+
gatherer_lanes: overrides.gatherer_lanes ?? [],
|
|
54
|
+
loader_lanes: overrides.loader_lanes ?? [],
|
|
55
|
+
generator: overrides.generator,
|
|
56
|
+
energy: overrides.energy !== undefined ? UInt16.from(overrides.energy) : undefined,
|
|
57
|
+
lanes: overrides.lanes ?? [],
|
|
58
|
+
coordinates: ServerContract.Types.coordinates.from({x: 0, y: 0}),
|
|
59
|
+
cargo: [],
|
|
60
|
+
cargomass: UInt32.from(0),
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const NOW = new Date('2026-06-21T00:00:00.000Z')
|
|
65
|
+
|
|
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
|
|
84
|
+
|
|
85
|
+
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,
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
const plan = planParallelGather(e, {quantity: QUANTITY}, STRATUM, NOW)
|
|
95
|
+
|
|
96
|
+
expect(plan).toHaveLength(2)
|
|
97
|
+
expect(plan.reduce((s, p) => s + p.quantity, 0)).toBe(QUANTITY)
|
|
98
|
+
|
|
99
|
+
const q1 = plan.find((p) => p.slot === 0)!.quantity
|
|
100
|
+
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)
|
|
119
|
+
})
|
|
120
|
+
|
|
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
|
+
})
|
|
127
|
+
|
|
128
|
+
const plan = planParallelGather(e, 'max', 0, NOW)
|
|
129
|
+
|
|
130
|
+
expect(plan.length).toBeGreaterThan(0)
|
|
131
|
+
for (const entry of plan) {
|
|
132
|
+
expect(entry.quantity).toBeGreaterThanOrEqual(1)
|
|
133
|
+
}
|
|
134
|
+
})
|
|
135
|
+
|
|
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)
|
|
149
|
+
|
|
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)
|
|
154
|
+
})
|
|
155
|
+
|
|
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
|
+
})
|
|
163
|
+
|
|
164
|
+
const plan = planParallelGather(e, {quantity: 120}, 0, NOW)
|
|
165
|
+
|
|
166
|
+
expect(plan).toHaveLength(1)
|
|
167
|
+
expect(plan[0].slot).toBe(0)
|
|
168
|
+
expect(plan[0].quantity).toBe(50)
|
|
169
|
+
})
|
|
170
|
+
|
|
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
|
+
})
|
|
189
|
+
|
|
190
|
+
const e = entity({
|
|
191
|
+
gatherer_lanes: [gathererLane(0, 100, 10000, 5000)],
|
|
192
|
+
generator: energyStats(10000, 1),
|
|
193
|
+
energy: 10000,
|
|
194
|
+
lanes: [queued],
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
const plan = planParallelGather(e, {quantity: 120}, 0, NOW)
|
|
198
|
+
|
|
199
|
+
expect(plan).toHaveLength(1)
|
|
200
|
+
expect(plan[0].quantity).toBe(30)
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
test('stratum filter: shallow lane excluded, only deep lane used', () => {
|
|
204
|
+
const e = entity({
|
|
205
|
+
gatherer_lanes: [gathererLane(0, 200, 500, 500), gathererLane(1, 300, 500, 5000)],
|
|
206
|
+
generator: energyStats(10000, 100),
|
|
207
|
+
energy: 10000,
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
const plan = planParallelGather(e, {quantity: 10}, 2000, NOW)
|
|
211
|
+
|
|
212
|
+
expect(plan).toHaveLength(1)
|
|
213
|
+
expect(plan[0].slot).toBe(1)
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
test('no reaching gatherers throws', () => {
|
|
217
|
+
const e = entity({
|
|
218
|
+
gatherer_lanes: [gathererLane(0, 200, 500, 100)],
|
|
219
|
+
generator: energyStats(10000, 100),
|
|
220
|
+
energy: 10000,
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
expect(() => planParallelGather(e, {quantity: 10}, 2000, NOW)).toThrow(
|
|
224
|
+
'no gatherer reaches this stratum'
|
|
225
|
+
)
|
|
226
|
+
})
|
|
227
|
+
|
|
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,
|
|
239
|
+
})
|
|
240
|
+
const eDouble = entity({
|
|
241
|
+
gatherer_lanes: [
|
|
242
|
+
gathererLane(0, YIELD, DRAIN, DEPTH),
|
|
243
|
+
gathererLane(1, YIELD, DRAIN, DEPTH),
|
|
244
|
+
],
|
|
245
|
+
generator: energyStats(10000, 100),
|
|
246
|
+
energy: 10000,
|
|
247
|
+
})
|
|
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)
|
|
274
|
+
})
|
|
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
|
+
|
|
283
|
+
const e = entity({
|
|
284
|
+
loader_lanes: [loaderLane(0, 500, THRUST1), loaderLane(1, 500, THRUST2)],
|
|
285
|
+
})
|
|
286
|
+
|
|
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)
|
|
291
|
+
|
|
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)
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
test('no loader lanes: returns empty plan', () => {
|
|
298
|
+
const plan = planParallelTransfer(entity({loader_lanes: []}), {quantity: 10})
|
|
299
|
+
expect(plan).toHaveLength(0)
|
|
300
|
+
})
|
|
301
|
+
|
|
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,
|
|
305
|
+
})
|
|
306
|
+
expect(plan).toHaveLength(0)
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
test("'max' target: each loader lane gets >= 1 unit", () => {
|
|
310
|
+
const e = entity({
|
|
311
|
+
loader_lanes: [loaderLane(0, 500, 100), loaderLane(1, 500, 200)],
|
|
312
|
+
})
|
|
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
|
+
}
|
|
318
|
+
})
|
|
319
|
+
})
|
|
@@ -287,7 +287,10 @@ function resolveEntity(
|
|
|
287
287
|
let moduleSlots: ResolvedModuleSlot[] | undefined
|
|
288
288
|
|
|
289
289
|
if (stats !== undefined) {
|
|
290
|
-
const
|
|
290
|
+
const bigStats = toBigStats(stats)
|
|
291
|
+
const decoded = decodeCraftedItemStats(id, bigStats)
|
|
292
|
+
if (decoded.strength === undefined) decoded.strength = decodeStat(bigStats, 0)
|
|
293
|
+
if (decoded.hardness === undefined) decoded.hardness = decodeStat(bigStats, 2)
|
|
291
294
|
const hullCaps = hullCapsForEntity(id, decoded)
|
|
292
295
|
attributes = [
|
|
293
296
|
{
|
|
@@ -26,6 +26,9 @@ function entity(tasks: ReturnType<typeof task>[], startedISO = T0) {
|
|
|
26
26
|
cargo: [],
|
|
27
27
|
modules: [],
|
|
28
28
|
lanes: [{lane_key: 0, schedule: {started: startedISO, tasks}}],
|
|
29
|
+
gatherer_lanes: [],
|
|
30
|
+
crafter_lanes: [],
|
|
31
|
+
loader_lanes: [],
|
|
29
32
|
holds: [],
|
|
30
33
|
})
|
|
31
34
|
}
|
|
@@ -147,6 +150,9 @@ describe('cancelEligibility — effects', () => {
|
|
|
147
150
|
cargo: [],
|
|
148
151
|
modules: [],
|
|
149
152
|
lanes: [{lane_key: 0, schedule: {started: T0, tasks: [lt]}}],
|
|
153
|
+
gatherer_lanes: [],
|
|
154
|
+
crafter_lanes: [],
|
|
155
|
+
loader_lanes: [],
|
|
150
156
|
holds: [
|
|
151
157
|
{
|
|
152
158
|
id: 1,
|
|
@@ -176,6 +182,9 @@ describe('cancelEligibility — effects', () => {
|
|
|
176
182
|
cargo: [],
|
|
177
183
|
modules: [],
|
|
178
184
|
lanes: [{lane_key: 0, schedule: {started: T0, tasks: [lt]}}],
|
|
185
|
+
gatherer_lanes: [],
|
|
186
|
+
crafter_lanes: [],
|
|
187
|
+
loader_lanes: [],
|
|
179
188
|
holds: [
|
|
180
189
|
{
|
|
181
190
|
id: 1,
|
|
@@ -205,6 +214,9 @@ describe('cancelEligibility — effects', () => {
|
|
|
205
214
|
cargo: [],
|
|
206
215
|
modules: [],
|
|
207
216
|
lanes: [{lane_key: 0, schedule: {started: T0, tasks: [lt]}}],
|
|
217
|
+
gatherer_lanes: [],
|
|
218
|
+
crafter_lanes: [],
|
|
219
|
+
loader_lanes: [],
|
|
208
220
|
holds: [],
|
|
209
221
|
})
|
|
210
222
|
const plan = cancelEligibility(e, 0, 0, {now: upcoming})
|
|
@@ -251,6 +263,9 @@ describe('cancelEligibility — feasibility', () => {
|
|
|
251
263
|
schedule: {started: '2026-06-19T00:00:00', tasks: [consumer]},
|
|
252
264
|
},
|
|
253
265
|
],
|
|
266
|
+
gatherer_lanes: [],
|
|
267
|
+
crafter_lanes: [],
|
|
268
|
+
loader_lanes: [],
|
|
254
269
|
holds: [],
|
|
255
270
|
})
|
|
256
271
|
expect(cancelEligibility(e, 1, 0, {now: upcoming}).blockedReason).toBe(
|
|
@@ -285,6 +300,9 @@ describe('cancelEligibility — feasibility', () => {
|
|
|
285
300
|
{lane_key: 1, schedule: {started: '2026-06-19T00:00:00', tasks: [producer]}},
|
|
286
301
|
{lane_key: 2, schedule: {started: '2026-06-19T00:00:00', tasks: [independent]}},
|
|
287
302
|
],
|
|
303
|
+
gatherer_lanes: [],
|
|
304
|
+
crafter_lanes: [],
|
|
305
|
+
loader_lanes: [],
|
|
288
306
|
holds: [],
|
|
289
307
|
})
|
|
290
308
|
expect(cancelEligibility(e, 2, 0, {now: upcoming}).ok).toBe(true)
|
|
@@ -318,6 +336,9 @@ describe('cancelEligibility — feasibility', () => {
|
|
|
318
336
|
{lane_key: 1, schedule: {started: '2026-06-19T00:00:00', tasks: [producer]}},
|
|
319
337
|
{lane_key: 2, schedule: {started: '2026-06-19T00:00:00', tasks: [consumer]}},
|
|
320
338
|
],
|
|
339
|
+
gatherer_lanes: [],
|
|
340
|
+
crafter_lanes: [],
|
|
341
|
+
loader_lanes: [],
|
|
321
342
|
holds: [],
|
|
322
343
|
})
|
|
323
344
|
expect(cancelEligibility(e, 1, 0, {now: upcoming}).blockedReason).toBe(
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import {UInt8, UInt16, UInt64} from '@wharfkit/antelope'
|
|
2
|
+
import {expect, test} from 'bun:test'
|
|
3
|
+
import {encodeStats} from '../derivation/crafting'
|
|
4
|
+
import {computeGathererYield, computeGathererDepth, computeGathererDrain} from '../nft/description'
|
|
5
|
+
import {computeLoaderThrust, computeLoaderMass} from '../nft/description'
|
|
6
|
+
import {computeCrafterSpeed, computeCrafterDrain} from '../nft/description'
|
|
7
|
+
import {applySlotMultiplier} from '../entities/slot-multiplier'
|
|
8
|
+
import {getSlotAmp} from '../entities/slot-multiplier'
|
|
9
|
+
import {getEntityLayout} from '../data/recipes-runtime'
|
|
10
|
+
import {ITEM_GATHERER_T1, ITEM_LOADER_T1, ITEM_CRAFTER_T1} from '../data/item-ids'
|
|
11
|
+
import {
|
|
12
|
+
workerLaneKey,
|
|
13
|
+
resolveLaneGatherer,
|
|
14
|
+
resolveLaneCrafter,
|
|
15
|
+
resolveLaneLoader,
|
|
16
|
+
selectGatherLane,
|
|
17
|
+
} from './lanes'
|
|
18
|
+
import {LANE_MOBILITY} from './schedule'
|
|
19
|
+
import type {ServerContract} from '../contracts'
|
|
20
|
+
|
|
21
|
+
type ModuleEntry = ServerContract.Types.module_entry
|
|
22
|
+
type Lane = ServerContract.Types.lane
|
|
23
|
+
|
|
24
|
+
// Real gatherer-bearing entity: extractor 10203 has [generator@0, gatherer@1].
|
|
25
|
+
const EXTRACTOR_GATHERER = 10203
|
|
26
|
+
const GATHERER_SLOT_IDX = 1
|
|
27
|
+
const GATHERER_LANE_KEY = GATHERER_SLOT_IDX + 1 // = 2
|
|
28
|
+
|
|
29
|
+
function makeModuleEntry(itemId: number, stats: bigint): ModuleEntry {
|
|
30
|
+
return {
|
|
31
|
+
type: UInt8.from(0),
|
|
32
|
+
installed: {
|
|
33
|
+
item_id: UInt16.from(itemId),
|
|
34
|
+
stats: UInt64.from(stats),
|
|
35
|
+
},
|
|
36
|
+
} as unknown as ModuleEntry
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function makeBusyLane(laneKey: number): Lane {
|
|
40
|
+
return {
|
|
41
|
+
lane_key: UInt8.from(laneKey),
|
|
42
|
+
schedule: {
|
|
43
|
+
started: {toDate: () => new Date()} as any,
|
|
44
|
+
tasks: [{duration: UInt64.from(100)} as any],
|
|
45
|
+
},
|
|
46
|
+
} as unknown as Lane
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// gatherer: stats = [str=300, tol=200, con=400], tier=1
|
|
50
|
+
const GATH_STR = 300
|
|
51
|
+
const GATH_TOL = 200
|
|
52
|
+
const GATH_CON = 400
|
|
53
|
+
const gathererStats1 = encodeStats([GATH_STR, GATH_TOL, GATH_CON])
|
|
54
|
+
|
|
55
|
+
// loader: stats = [ins=300, pla=500]
|
|
56
|
+
const LOADER_INS = 300
|
|
57
|
+
const LOADER_PLA = 500
|
|
58
|
+
const loaderStats = encodeStats([LOADER_INS, LOADER_PLA])
|
|
59
|
+
|
|
60
|
+
// crafter: stats = [rea=400, fin=300]
|
|
61
|
+
const CRAFTER_REA = 400
|
|
62
|
+
const CRAFTER_FIN = 300
|
|
63
|
+
const crafterStats = encodeStats([CRAFTER_REA, CRAFTER_FIN])
|
|
64
|
+
|
|
65
|
+
// --- resolveLaneGatherer ---
|
|
66
|
+
|
|
67
|
+
test('resolveLaneGatherer reads the layout slot amp and applies it to yield (parity formula)', () => {
|
|
68
|
+
// Gatherer at the entity's real gatherer slot (index 1 => laneKey 2); amp comes from getEntityLayout(10203).
|
|
69
|
+
const modules: ModuleEntry[] = [
|
|
70
|
+
makeModuleEntry(ITEM_GATHERER_T1, gathererStats1), // slot 0 (generator slot, ignored)
|
|
71
|
+
makeModuleEntry(ITEM_GATHERER_T1, gathererStats1), // slot 1 (gatherer slot)
|
|
72
|
+
]
|
|
73
|
+
const result = resolveLaneGatherer(modules, EXTRACTOR_GATHERER, GATHERER_LANE_KEY)
|
|
74
|
+
|
|
75
|
+
const layout = getEntityLayout(EXTRACTOR_GATHERER)?.slots ?? []
|
|
76
|
+
const ampFromLayout = getSlotAmp(layout, GATHERER_SLOT_IDX)
|
|
77
|
+
|
|
78
|
+
// The resolver routes through the real layout's amp, not a hardcoded 100.
|
|
79
|
+
expect(result.outputPct).toBe(ampFromLayout)
|
|
80
|
+
// Yield equals the contract formula clamp_to_uint16(compute_gatherer_yield(str) * amp / 100).
|
|
81
|
+
expect(result.yield).toBe(applySlotMultiplier(computeGathererYield(GATH_STR), ampFromLayout))
|
|
82
|
+
expect(result.drain).toBe(computeGathererDrain(GATH_CON))
|
|
83
|
+
expect(result.depth).toBe(computeGathererDepth(GATH_TOL, 1))
|
|
84
|
+
expect(result.slotIndex).toBe(GATHERER_SLOT_IDX)
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
test('resolveLaneGatherer amp-scaling parity holds for a non-100 amp', () => {
|
|
88
|
+
// Parity for a non-100 amp: clamp_to_uint16(value * amp / 100).
|
|
89
|
+
expect(applySlotMultiplier(computeGathererYield(GATH_STR), 80)).toBe(
|
|
90
|
+
Math.min(Math.floor((computeGathererYield(GATH_STR) * 80) / 100), 65535)
|
|
91
|
+
)
|
|
92
|
+
expect(applySlotMultiplier(computeGathererYield(GATH_STR), 120)).toBe(
|
|
93
|
+
Math.floor((computeGathererYield(GATH_STR) * 120) / 100)
|
|
94
|
+
)
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
test('resolveLaneGatherer throws on out-of-range laneKey', () => {
|
|
98
|
+
const modules: ModuleEntry[] = [makeModuleEntry(ITEM_GATHERER_T1, gathererStats1)]
|
|
99
|
+
expect(() => resolveLaneGatherer(modules, ITEM_GATHERER_T1, 5)).toThrow(
|
|
100
|
+
'gatherer lane has no module'
|
|
101
|
+
)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
test('resolveLaneGatherer throws on laneKey=0 (slot=255 off-by-one boundary)', () => {
|
|
105
|
+
const modules: ModuleEntry[] = [makeModuleEntry(ITEM_GATHERER_T1, gathererStats1)]
|
|
106
|
+
expect(() => resolveLaneGatherer(modules, ITEM_GATHERER_T1, 0)).toThrow(
|
|
107
|
+
'gatherer lane has no module'
|
|
108
|
+
)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
// --- resolveLaneCrafter ---
|
|
112
|
+
|
|
113
|
+
test('resolveLaneCrafter returns correct stats for slot 0 (laneKey=1)', () => {
|
|
114
|
+
const modules: ModuleEntry[] = [makeModuleEntry(ITEM_CRAFTER_T1, crafterStats)]
|
|
115
|
+
const result = resolveLaneCrafter(modules, ITEM_CRAFTER_T1, 1)
|
|
116
|
+
const layout = getEntityLayout(ITEM_CRAFTER_T1)?.slots ?? []
|
|
117
|
+
const amp = getSlotAmp(layout, 0)
|
|
118
|
+
expect(result.slotIndex).toBe(0)
|
|
119
|
+
expect(result.speed).toBe(applySlotMultiplier(computeCrafterSpeed(CRAFTER_REA), amp))
|
|
120
|
+
expect(result.drain).toBe(computeCrafterDrain(CRAFTER_FIN))
|
|
121
|
+
expect(result.outputPct).toBe(amp)
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
test('resolveLaneCrafter throws on out-of-range laneKey', () => {
|
|
125
|
+
const modules: ModuleEntry[] = [makeModuleEntry(ITEM_CRAFTER_T1, crafterStats)]
|
|
126
|
+
expect(() => resolveLaneCrafter(modules, ITEM_CRAFTER_T1, 5)).toThrow(
|
|
127
|
+
'crafter lane has no module'
|
|
128
|
+
)
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
test('resolveLaneCrafter throws on laneKey=0 (slot=255 off-by-one boundary)', () => {
|
|
132
|
+
const modules: ModuleEntry[] = [makeModuleEntry(ITEM_CRAFTER_T1, crafterStats)]
|
|
133
|
+
expect(() => resolveLaneCrafter(modules, ITEM_CRAFTER_T1, 0)).toThrow(
|
|
134
|
+
'crafter lane has no module'
|
|
135
|
+
)
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
// --- resolveLaneLoader ---
|
|
139
|
+
|
|
140
|
+
test('resolveLaneLoader returns correct stats for slot 0 (laneKey=1)', () => {
|
|
141
|
+
const modules: ModuleEntry[] = [makeModuleEntry(ITEM_LOADER_T1, loaderStats)]
|
|
142
|
+
const result = resolveLaneLoader(modules, ITEM_LOADER_T1, 1)
|
|
143
|
+
const layout = getEntityLayout(ITEM_LOADER_T1)?.slots ?? []
|
|
144
|
+
const amp = getSlotAmp(layout, 0)
|
|
145
|
+
expect(result.valid).toBe(true)
|
|
146
|
+
expect(result.slotIndex).toBe(0)
|
|
147
|
+
expect(result.thrust).toBe(applySlotMultiplier(computeLoaderThrust(LOADER_PLA), amp))
|
|
148
|
+
expect(result.mass).toBe(computeLoaderMass(LOADER_INS))
|
|
149
|
+
expect(result.outputPct).toBe(amp)
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
test('resolveLaneLoader soft-returns invalid for LANE_MOBILITY (key 0), never throws', () => {
|
|
153
|
+
const modules: ModuleEntry[] = [makeModuleEntry(ITEM_LOADER_T1, loaderStats)]
|
|
154
|
+
const result = resolveLaneLoader(modules, ITEM_LOADER_T1, LANE_MOBILITY)
|
|
155
|
+
expect(result.valid).toBe(false)
|
|
156
|
+
expect(result.thrust).toBe(0)
|
|
157
|
+
expect(result.mass).toBe(0)
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
test('resolveLaneLoader soft-returns invalid for a missing/out-of-range module, never throws', () => {
|
|
161
|
+
const modules: ModuleEntry[] = [makeModuleEntry(ITEM_LOADER_T1, loaderStats)]
|
|
162
|
+
const result = resolveLaneLoader(modules, ITEM_LOADER_T1, 5)
|
|
163
|
+
expect(result.valid).toBe(false)
|
|
164
|
+
expect(result.thrust).toBe(0)
|
|
165
|
+
expect(result.mass).toBe(0)
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
// --- depth-aware workerLaneKey ---
|
|
169
|
+
|
|
170
|
+
test('workerLaneKey with stratum picks first-free reaching gatherer', () => {
|
|
171
|
+
const shallowStats = encodeStats([100, 200, 100]) // tol=200 => depth=1500 for tier1
|
|
172
|
+
const deepStats = encodeStats([100, 900, 100]) // tol=900 => depth=5000 for tier1
|
|
173
|
+
const modules: ModuleEntry[] = [
|
|
174
|
+
makeModuleEntry(ITEM_GATHERER_T1, shallowStats),
|
|
175
|
+
makeModuleEntry(ITEM_GATHERER_T1, deepStats),
|
|
176
|
+
]
|
|
177
|
+
const laneKey = workerLaneKey(modules, 'gatherer', [], 2000)
|
|
178
|
+
expect(laneKey).toBe(2) // slot 1 => laneKey 2
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
test('workerLaneKey with stratum: first free reaching is preferred over busy reaching', () => {
|
|
182
|
+
const deepStats = encodeStats([100, 900, 100]) // tol=900 => depth=5000 for tier1
|
|
183
|
+
const modules: ModuleEntry[] = [
|
|
184
|
+
makeModuleEntry(ITEM_GATHERER_T1, deepStats),
|
|
185
|
+
makeModuleEntry(ITEM_GATHERER_T1, deepStats),
|
|
186
|
+
]
|
|
187
|
+
const lanes: Lane[] = [makeBusyLane(1)] // slot 0 is busy
|
|
188
|
+
const laneKey = workerLaneKey(modules, 'gatherer', lanes, 2000)
|
|
189
|
+
expect(laneKey).toBe(2) // slot 1 (laneKey=2) is free and reaching
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
test('workerLaneKey with stratum: returns lowest reaching (busy) when all reaching are busy', () => {
|
|
193
|
+
const deepStats = encodeStats([100, 900, 100]) // tol=900 => depth=5000 for tier1
|
|
194
|
+
const modules: ModuleEntry[] = [
|
|
195
|
+
makeModuleEntry(ITEM_GATHERER_T1, deepStats),
|
|
196
|
+
makeModuleEntry(ITEM_GATHERER_T1, deepStats),
|
|
197
|
+
]
|
|
198
|
+
const lanes: Lane[] = [makeBusyLane(1), makeBusyLane(2)]
|
|
199
|
+
const laneKey = workerLaneKey(modules, 'gatherer', lanes, 2000)
|
|
200
|
+
expect(laneKey).toBe(1) // lowest reaching is slot 0 => laneKey 1
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
test('workerLaneKey with stratum throws "no gatherer reaches this stratum" when none reach', () => {
|
|
204
|
+
const shallowStats = encodeStats([100, 10, 100]) // tol=10 => depth=550 for tier1
|
|
205
|
+
const modules: ModuleEntry[] = [makeModuleEntry(ITEM_GATHERER_T1, shallowStats)]
|
|
206
|
+
expect(() => workerLaneKey(modules, 'gatherer', [], 5000)).toThrow(
|
|
207
|
+
'no gatherer reaches this stratum'
|
|
208
|
+
)
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
test('workerLaneKey without stratum still works (non-gatherer path)', () => {
|
|
212
|
+
const modules: ModuleEntry[] = [makeModuleEntry(ITEM_CRAFTER_T1, crafterStats)]
|
|
213
|
+
const laneKey = workerLaneKey(modules, 'crafter', [])
|
|
214
|
+
expect(laneKey).toBe(1)
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
// --- selectGatherLane: both real error paths ---
|
|
218
|
+
|
|
219
|
+
test('selectGatherLane auto-pick returns the depth-aware lane', () => {
|
|
220
|
+
const shallowStats = encodeStats([100, 200, 100]) // depth=1500 for tier1
|
|
221
|
+
const deepStats = encodeStats([100, 900, 100]) // depth=5000 for tier1
|
|
222
|
+
const modules: ModuleEntry[] = [
|
|
223
|
+
makeModuleEntry(ITEM_GATHERER_T1, shallowStats),
|
|
224
|
+
makeModuleEntry(ITEM_GATHERER_T1, deepStats),
|
|
225
|
+
]
|
|
226
|
+
expect(selectGatherLane(modules, ITEM_GATHERER_T1, [], 2000)).toBe(2)
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
test('selectGatherLane auto-pick throws "no gatherer reaches this stratum" when none reach', () => {
|
|
230
|
+
const shallowStats = encodeStats([100, 10, 100]) // depth=550 for tier1
|
|
231
|
+
const modules: ModuleEntry[] = [makeModuleEntry(ITEM_GATHERER_T1, shallowStats)]
|
|
232
|
+
expect(() => selectGatherLane(modules, ITEM_GATHERER_T1, [], 5000)).toThrow(
|
|
233
|
+
'no gatherer reaches this stratum'
|
|
234
|
+
)
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
test('selectGatherLane explicit slot returns its laneKey when stratum is within depth', () => {
|
|
238
|
+
const deepStats = encodeStats([100, 900, 100]) // depth=5000 for tier1
|
|
239
|
+
const modules: ModuleEntry[] = [makeModuleEntry(ITEM_GATHERER_T1, deepStats)]
|
|
240
|
+
expect(selectGatherLane(modules, ITEM_GATHERER_T1, [], 2000, 0)).toBe(1) // slot 0 => laneKey 1
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
test('selectGatherLane explicit slot too shallow throws "stratum exceeds gatherer depth"', () => {
|
|
244
|
+
const shallowStats = encodeStats([100, 10, 100]) // depth=550 for tier1
|
|
245
|
+
const modules: ModuleEntry[] = [makeModuleEntry(ITEM_GATHERER_T1, shallowStats)]
|
|
246
|
+
expect(() => selectGatherLane(modules, ITEM_GATHERER_T1, [], 5000, 0)).toThrow(
|
|
247
|
+
'stratum exceeds gatherer depth'
|
|
248
|
+
)
|
|
249
|
+
})
|