@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
package/package.json
CHANGED
|
@@ -12,10 +12,10 @@ export function capsHasCrafter(caps: EntityCapabilities): boolean {
|
|
|
12
12
|
|
|
13
13
|
export function calc_craft_duration(speed: number, totalInputMass: number): UInt32 {
|
|
14
14
|
const duration = Math.floor(totalInputMass / speed)
|
|
15
|
-
return UInt32.from(
|
|
15
|
+
return UInt32.from(duration + 1)
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
export function calc_craft_energy(drain: number, totalInputMass: number): UInt32 {
|
|
19
19
|
const raw = Math.floor((totalInputMass * drain) / CRAFT_ENERGY_DIVISOR)
|
|
20
|
-
return UInt32.from(Math.min(Math.max(raw, 1), 4294967295))
|
|
20
|
+
return UInt32.from(Math.min(Math.max(raw + 1, 1), 4294967295))
|
|
21
21
|
}
|
package/src/index-module.ts
CHANGED
|
@@ -570,5 +570,21 @@ export type {DescribeOptions} from './resolution/display-name'
|
|
|
570
570
|
|
|
571
571
|
export * from './subscriptions'
|
|
572
572
|
|
|
573
|
-
export {
|
|
574
|
-
|
|
573
|
+
export {
|
|
574
|
+
allocateProportional,
|
|
575
|
+
buildGatherPlan,
|
|
576
|
+
gatherEnergyCost,
|
|
577
|
+
maxQtyForCharge,
|
|
578
|
+
planParallelTransfer,
|
|
579
|
+
splitCost,
|
|
580
|
+
} from './planner'
|
|
581
|
+
export type {
|
|
582
|
+
BuildGatherPlanOpts,
|
|
583
|
+
FillCap,
|
|
584
|
+
GatherCycle,
|
|
585
|
+
GatherLimpet,
|
|
586
|
+
GatherPlan,
|
|
587
|
+
GatherPlanEntity,
|
|
588
|
+
LanePlanEntry,
|
|
589
|
+
PlanTarget,
|
|
590
|
+
} from './planner'
|
package/src/managers/actions.ts
CHANGED
|
@@ -21,6 +21,7 @@ import {Coordinates, PRECISION, type ClusterSlotType, type CoordinatesType} from
|
|
|
21
21
|
import {ServerContract} from '../contracts'
|
|
22
22
|
import {ATOMICASSETS_ABI, SHIPLOAD_COLLECTION} from '../nft/atomicassets'
|
|
23
23
|
import {getItem} from '../data/catalog'
|
|
24
|
+
import type {GatherPlan} from '../planner'
|
|
24
25
|
|
|
25
26
|
const CHARGE_K = 1n
|
|
26
27
|
const ENERGY_DIVISOR = 1_000_000n
|
|
@@ -394,6 +395,29 @@ export class ActionsManager extends BaseManager {
|
|
|
394
395
|
})
|
|
395
396
|
}
|
|
396
397
|
|
|
398
|
+
// Mirrors the contract's projected_energy walk: recharge (if due) then gathers, per cycle.
|
|
399
|
+
flattenGatherPlan(
|
|
400
|
+
plan: GatherPlan,
|
|
401
|
+
ctx: {sourceId: UInt64Type; destinationId: UInt64Type; stratum: UInt16Type}
|
|
402
|
+
): Action[] {
|
|
403
|
+
const actions: Action[] = []
|
|
404
|
+
for (const cycle of plan.cycles) {
|
|
405
|
+
if (cycle.rechargeBefore) actions.push(this.recharge(ctx.sourceId))
|
|
406
|
+
for (const limpet of cycle.limpets) {
|
|
407
|
+
actions.push(
|
|
408
|
+
this.gather(
|
|
409
|
+
ctx.sourceId,
|
|
410
|
+
ctx.destinationId,
|
|
411
|
+
ctx.stratum,
|
|
412
|
+
limpet.quantity,
|
|
413
|
+
limpet.slot
|
|
414
|
+
)
|
|
415
|
+
)
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
return actions
|
|
419
|
+
}
|
|
420
|
+
|
|
397
421
|
warp(entityId: UInt64Type, destination: CoordinatesType): Action {
|
|
398
422
|
const x = Int64.from(destination.x)
|
|
399
423
|
const y = Int64.from(destination.y)
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import {expect, test, describe} from 'bun:test'
|
|
2
|
+
import {makeClient} from '@wharfkit/mock-data'
|
|
3
|
+
import {Serializer} from '@wharfkit/antelope'
|
|
4
|
+
import type {GatherPlan} from '../planner'
|
|
5
|
+
import {ActionsManager} from './actions'
|
|
6
|
+
import {ServerContract} from '../contracts'
|
|
7
|
+
|
|
8
|
+
const client = makeClient('https://jungle4.greymass.com')
|
|
9
|
+
|
|
10
|
+
function makeActions() {
|
|
11
|
+
const realServer = new ServerContract.Contract({client})
|
|
12
|
+
const stubServer = {
|
|
13
|
+
account: realServer.account,
|
|
14
|
+
action: realServer.action.bind(realServer),
|
|
15
|
+
}
|
|
16
|
+
const context = {server: stubServer} as any
|
|
17
|
+
return new ActionsManager(context)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function fakePlan(): GatherPlan {
|
|
21
|
+
return {
|
|
22
|
+
cycles: [
|
|
23
|
+
{
|
|
24
|
+
rechargeBefore: false,
|
|
25
|
+
rechargeSeconds: 0,
|
|
26
|
+
gatherSeconds: 10,
|
|
27
|
+
batchOre: 30,
|
|
28
|
+
limpets: [
|
|
29
|
+
{slot: 0, quantity: 20, durationSeconds: 10},
|
|
30
|
+
{slot: 1, quantity: 10, durationSeconds: 8},
|
|
31
|
+
],
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
rechargeBefore: true,
|
|
35
|
+
rechargeSeconds: 5,
|
|
36
|
+
gatherSeconds: 10,
|
|
37
|
+
batchOre: 30,
|
|
38
|
+
limpets: [{slot: 0, quantity: 30, durationSeconds: 10}],
|
|
39
|
+
},
|
|
40
|
+
],
|
|
41
|
+
cycleCount: 2,
|
|
42
|
+
totalOre: 60,
|
|
43
|
+
totalSeconds: 25,
|
|
44
|
+
cap: 'requested',
|
|
45
|
+
reachingCount: 2,
|
|
46
|
+
totalLimpets: 2,
|
|
47
|
+
warnings: [],
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe('flattenGatherPlan', () => {
|
|
52
|
+
test('flattens cycles to [recharge?, gather x limpets] in order with slots', () => {
|
|
53
|
+
const actions = makeActions().flattenGatherPlan(fakePlan(), {
|
|
54
|
+
sourceId: 1,
|
|
55
|
+
destinationId: 1,
|
|
56
|
+
stratum: 100,
|
|
57
|
+
})
|
|
58
|
+
expect(actions.map((a) => a.name.toString())).toEqual([
|
|
59
|
+
'gather',
|
|
60
|
+
'gather',
|
|
61
|
+
'recharge',
|
|
62
|
+
'gather',
|
|
63
|
+
])
|
|
64
|
+
expect(actions.filter((a) => a.name.toString() === 'recharge').length).toBe(1)
|
|
65
|
+
const gatherSlots = actions
|
|
66
|
+
.filter((a) => a.name.toString() === 'gather')
|
|
67
|
+
.map((a) => {
|
|
68
|
+
const decoded = Serializer.decode({data: a.data, type: ServerContract.Types.gather})
|
|
69
|
+
return Number(decoded.slot)
|
|
70
|
+
})
|
|
71
|
+
expect(gatherSlots).toEqual([0, 1, 0])
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
test('empty plan produces no actions', () => {
|
|
75
|
+
const empty = {...fakePlan(), cycles: [], cycleCount: 0, totalOre: 0}
|
|
76
|
+
expect(
|
|
77
|
+
makeActions().flattenGatherPlan(empty, {sourceId: 1, destinationId: 1, stratum: 100})
|
|
78
|
+
).toHaveLength(0)
|
|
79
|
+
})
|
|
80
|
+
})
|
package/src/planner/index.ts
CHANGED
|
@@ -1,10 +1,7 @@
|
|
|
1
1
|
import type {GathererStats} from '../types/capabilities'
|
|
2
2
|
import type {ServerContract} from '../contracts'
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
calc_gather_energy,
|
|
6
|
-
GATHER_MASS_DIVISOR,
|
|
7
|
-
} from '../capabilities/gathering'
|
|
3
|
+
import {calc_gather_duration, calc_gather_energy} from '../capabilities/gathering'
|
|
4
|
+
import {calc_rechargetime} from '../travel/travel'
|
|
8
5
|
import {projectRemainingAt, type Projectable} from '../scheduling/projection'
|
|
9
6
|
|
|
10
7
|
export interface LanePlanEntry {
|
|
@@ -19,26 +16,46 @@ export interface GatherPlanEntity extends Projectable {
|
|
|
19
16
|
loader_lanes: ServerContract.Types.loader_lane[]
|
|
20
17
|
}
|
|
21
18
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
19
|
+
export interface GatherLimpet {
|
|
20
|
+
slot: number
|
|
21
|
+
quantity: number
|
|
22
|
+
durationSeconds: number
|
|
23
|
+
}
|
|
25
24
|
|
|
26
|
-
|
|
27
|
-
|
|
25
|
+
export interface GatherCycle {
|
|
26
|
+
rechargeBefore: boolean
|
|
27
|
+
rechargeSeconds: number
|
|
28
|
+
limpets: GatherLimpet[]
|
|
29
|
+
gatherSeconds: number
|
|
30
|
+
batchOre: number
|
|
31
|
+
}
|
|
28
32
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
33
|
+
export type FillCap = 'reserve' | 'hold' | 'requested'
|
|
34
|
+
|
|
35
|
+
export interface GatherPlan {
|
|
36
|
+
cycles: GatherCycle[]
|
|
37
|
+
cycleCount: number
|
|
38
|
+
totalOre: number
|
|
39
|
+
totalSeconds: number
|
|
40
|
+
cap: FillCap
|
|
41
|
+
reachingCount: number
|
|
42
|
+
totalLimpets: number
|
|
43
|
+
warnings: string[]
|
|
39
44
|
}
|
|
40
45
|
|
|
41
|
-
|
|
46
|
+
export interface BuildGatherPlanOpts {
|
|
47
|
+
richness: number
|
|
48
|
+
itemMass: number
|
|
49
|
+
holdRoom: number
|
|
50
|
+
reserveRemaining: number
|
|
51
|
+
now: Date
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const MAX_CYCLES = 10_000
|
|
55
|
+
|
|
56
|
+
const MAX_TRANSFER_QTY = 10000
|
|
57
|
+
|
|
58
|
+
export function allocateProportional(
|
|
42
59
|
lanes: {slot: number; weight: number}[],
|
|
43
60
|
total: number
|
|
44
61
|
): LanePlanEntry[] {
|
|
@@ -60,53 +77,151 @@ function allocateProportional(
|
|
|
60
77
|
return entries
|
|
61
78
|
}
|
|
62
79
|
|
|
63
|
-
|
|
80
|
+
function laneStats(lane: ServerContract.Types.gatherer_lane): GathererStats {
|
|
81
|
+
return lane as unknown as GathererStats
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function gatherEnergyCost(
|
|
85
|
+
lane: ServerContract.Types.gatherer_lane,
|
|
86
|
+
quantity: number,
|
|
87
|
+
stratum: number,
|
|
88
|
+
itemMass: number,
|
|
89
|
+
richness: number
|
|
90
|
+
): number {
|
|
91
|
+
if (quantity <= 0) return 0
|
|
92
|
+
const dur = Number(calc_gather_duration(laneStats(lane), itemMass, quantity, stratum, richness))
|
|
93
|
+
return Number(calc_gather_energy(laneStats(lane), dur))
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function splitCost(
|
|
97
|
+
reaching: ServerContract.Types.gatherer_lane[],
|
|
98
|
+
quantity: number,
|
|
99
|
+
stratum: number,
|
|
100
|
+
itemMass: number,
|
|
101
|
+
richness: number
|
|
102
|
+
): number {
|
|
103
|
+
if (quantity <= 0) return 0
|
|
104
|
+
const weights = reaching.map((l) => ({
|
|
105
|
+
slot: l.slot_index.toNumber(),
|
|
106
|
+
weight: l.yield.toNumber(),
|
|
107
|
+
}))
|
|
108
|
+
const split = allocateProportional(weights, quantity)
|
|
109
|
+
return split.reduce((sum, e) => {
|
|
110
|
+
const lane = reaching.find((l) => l.slot_index.toNumber() === e.slot)!
|
|
111
|
+
return sum + gatherEnergyCost(lane, e.quantity, stratum, itemMass, richness)
|
|
112
|
+
}, 0)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Binary search is exact because splitCost is monotonic non-decreasing in Q.
|
|
116
|
+
export function maxQtyForCharge(
|
|
117
|
+
reaching: ServerContract.Types.gatherer_lane[],
|
|
118
|
+
hi: number,
|
|
119
|
+
capacity: number,
|
|
120
|
+
stratum: number,
|
|
121
|
+
itemMass: number,
|
|
122
|
+
richness: number
|
|
123
|
+
): number {
|
|
124
|
+
if (hi <= 0) return 0
|
|
125
|
+
if (splitCost(reaching, hi, stratum, itemMass, richness) <= capacity) return hi
|
|
126
|
+
let lo = 0
|
|
127
|
+
let high = hi
|
|
128
|
+
while (lo < high) {
|
|
129
|
+
const mid = Math.ceil((lo + high) / 2)
|
|
130
|
+
if (splitCost(reaching, mid, stratum, itemMass, richness) <= capacity) lo = mid
|
|
131
|
+
else high = mid - 1
|
|
132
|
+
}
|
|
133
|
+
return lo
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function buildGatherPlan(
|
|
64
137
|
entity: GatherPlanEntity,
|
|
65
|
-
target: PlanTarget,
|
|
66
138
|
stratum: number,
|
|
67
|
-
|
|
68
|
-
|
|
139
|
+
target: PlanTarget,
|
|
140
|
+
opts: BuildGatherPlanOpts
|
|
141
|
+
): GatherPlan {
|
|
142
|
+
const {richness, itemMass, holdRoom, reserveRemaining, now} = opts
|
|
143
|
+
const warnings: string[] = []
|
|
144
|
+
|
|
69
145
|
const reaching = entity.gatherer_lanes.filter((l) => l.depth.toNumber() >= stratum)
|
|
70
146
|
if (reaching.length === 0) throw new Error('no gatherer reaches this stratum')
|
|
147
|
+
if (!entity.generator) throw new Error('entity has no generator')
|
|
71
148
|
|
|
72
|
-
|
|
73
|
-
|
|
149
|
+
const blocked = entity.gatherer_lanes.length - reaching.length
|
|
150
|
+
if (blocked > 0) {
|
|
151
|
+
warnings.push(
|
|
152
|
+
`${blocked} of ${entity.gatherer_lanes.length} limpets can't reach this depth`
|
|
153
|
+
)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const capacity = entity.generator.capacity.toNumber()
|
|
157
|
+
const rechargeRate = entity.generator.recharge.toNumber()
|
|
158
|
+
let energy = Number(projectRemainingAt(entity, now).energy)
|
|
74
159
|
|
|
75
|
-
const
|
|
160
|
+
const requested = target === 'max' ? Infinity : target.quantity
|
|
161
|
+
let fillTarget = requested
|
|
162
|
+
let cap: FillCap = 'requested'
|
|
163
|
+
if (holdRoom < fillTarget) {
|
|
164
|
+
fillTarget = holdRoom
|
|
165
|
+
cap = 'hold'
|
|
166
|
+
}
|
|
167
|
+
if (reserveRemaining < fillTarget) {
|
|
168
|
+
fillTarget = reserveRemaining
|
|
169
|
+
cap = 'reserve'
|
|
170
|
+
}
|
|
171
|
+
fillTarget = Math.max(0, Math.floor(fillTarget))
|
|
172
|
+
|
|
173
|
+
const cycles: GatherCycle[] = []
|
|
174
|
+
let remaining = fillTarget
|
|
175
|
+
let guard = 0
|
|
176
|
+
while (remaining > 0 && guard++ < MAX_CYCLES) {
|
|
177
|
+
const batch = maxQtyForCharge(reaching, remaining, capacity, stratum, itemMass, richness)
|
|
178
|
+
if (batch <= 0) {
|
|
179
|
+
warnings.push('a single gather cannot fit within one full charge')
|
|
180
|
+
break
|
|
181
|
+
}
|
|
76
182
|
|
|
77
|
-
|
|
78
|
-
|
|
183
|
+
const costFull = splitCost(reaching, batch, stratum, itemMass, richness)
|
|
184
|
+
const rechargeBefore = energy < costFull
|
|
185
|
+
const rechargeSeconds = rechargeBefore
|
|
186
|
+
? Number(calc_rechargetime(capacity, energy, rechargeRate))
|
|
187
|
+
: 0
|
|
188
|
+
if (rechargeBefore) energy = capacity
|
|
79
189
|
|
|
80
|
-
|
|
81
|
-
const laneWeights = activeLanes.map((l) => ({
|
|
190
|
+
const weights = reaching.map((l) => ({
|
|
82
191
|
slot: l.slot_index.toNumber(),
|
|
83
192
|
weight: l.yield.toNumber(),
|
|
84
193
|
}))
|
|
85
|
-
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
194
|
+
const split = allocateProportional(weights, batch).filter((e) => e.quantity > 0)
|
|
195
|
+
const limpets: GatherLimpet[] = split.map((e) => {
|
|
196
|
+
const lane = reaching.find((l) => l.slot_index.toNumber() === e.slot)!
|
|
197
|
+
const dur = Number(
|
|
198
|
+
calc_gather_duration(laneStats(lane), itemMass, e.quantity, stratum, richness)
|
|
199
|
+
)
|
|
200
|
+
return {slot: e.slot, quantity: e.quantity, durationSeconds: dur}
|
|
201
|
+
})
|
|
202
|
+
const actualCost = limpets.reduce((sum, l) => {
|
|
203
|
+
const lane = reaching.find((r) => r.slot_index.toNumber() === l.slot)!
|
|
204
|
+
return sum + Number(calc_gather_energy(laneStats(lane), l.durationSeconds))
|
|
91
205
|
}, 0)
|
|
206
|
+
energy = Math.max(0, energy - actualCost)
|
|
92
207
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
if (activeLanes.length === 1) {
|
|
98
|
-
const lane = activeLanes[0]
|
|
99
|
-
const energyPerUnit = gatherEnergyCost(lane, 1, stratum)
|
|
100
|
-
if (energyPerUnit === 0) return proposed.filter((e) => e.quantity > 0)
|
|
101
|
-
const maxQty = Math.min(requestedQty, Math.floor(energy / energyPerUnit))
|
|
102
|
-
if (maxQty <= 0) return []
|
|
103
|
-
return [{slot: lane.slot_index.toNumber(), quantity: maxQty}]
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
activeLanes = activeLanes.slice(1)
|
|
208
|
+
const gatherSeconds = limpets.reduce((m, l) => Math.max(m, l.durationSeconds), 0)
|
|
209
|
+
cycles.push({rechargeBefore, rechargeSeconds, limpets, gatherSeconds, batchOre: batch})
|
|
210
|
+
remaining -= batch
|
|
107
211
|
}
|
|
108
212
|
|
|
109
|
-
|
|
213
|
+
const totalOre = cycles.reduce((s, c) => s + c.batchOre, 0)
|
|
214
|
+
const totalSeconds = cycles.reduce((s, c) => s + c.rechargeSeconds + c.gatherSeconds, 0)
|
|
215
|
+
return {
|
|
216
|
+
cycles,
|
|
217
|
+
cycleCount: cycles.length,
|
|
218
|
+
totalOre,
|
|
219
|
+
totalSeconds,
|
|
220
|
+
cap,
|
|
221
|
+
reachingCount: reaching.length,
|
|
222
|
+
totalLimpets: entity.gatherer_lanes.length,
|
|
223
|
+
warnings,
|
|
224
|
+
}
|
|
110
225
|
}
|
|
111
226
|
|
|
112
227
|
export function planParallelTransfer(
|
|
@@ -116,7 +231,7 @@ export function planParallelTransfer(
|
|
|
116
231
|
const lanes = entity.loader_lanes.filter((l) => l.thrust.toNumber() > 0)
|
|
117
232
|
if (lanes.length === 0) return []
|
|
118
233
|
|
|
119
|
-
const requestedQty = target === 'max' ?
|
|
234
|
+
const requestedQty = target === 'max' ? MAX_TRANSFER_QTY : target.quantity
|
|
120
235
|
|
|
121
236
|
const laneWeights = lanes.map((l) => ({
|
|
122
237
|
slot: l.slot_index.toNumber(),
|