@shipload/sdk 1.0.0-next.47 → 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.
@@ -150,6 +150,8 @@ export type {ReserveTier, TierRange} from './derivation'
150
150
  export {getEffectiveReserve} from './derivation'
151
151
  export type {EffectiveReserveInput} from './derivation'
152
152
 
153
+ export {eligibleUpgrades} from './derivation'
154
+
153
155
  export {getStatDefinitions, getStatName, resolveStats} from './derivation'
154
156
  export type {StatDefinition, NamedStats} from './derivation'
155
157
 
@@ -568,5 +570,21 @@ export type {DescribeOptions} from './resolution/display-name'
568
570
 
569
571
  export * from './subscriptions'
570
572
 
571
- export {planParallelGather, planParallelTransfer} from './planner'
572
- export type {LanePlanEntry, PlanTarget, GatherPlanEntity} from './planner'
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'
@@ -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)
@@ -447,6 +471,25 @@ export class ActionsManager extends BaseManager {
447
471
  })
448
472
  }
449
473
 
474
+ upgrade(
475
+ builderId: UInt64Type,
476
+ targetId: UInt64Type,
477
+ targetItemId: UInt16Type,
478
+ inputs: ServerContract.ActionParams.Type.cargo_item[],
479
+ slot?: UInt8Type
480
+ ): Action {
481
+ const params: ServerContract.ActionParams.upgrade = {
482
+ builder_id: UInt64.from(builderId),
483
+ target_id: UInt64.from(targetId),
484
+ target_item_id: UInt16.from(targetItemId),
485
+ inputs,
486
+ }
487
+ if (slot !== undefined) {
488
+ params.slot = UInt8.from(slot)
489
+ }
490
+ return this.server.action('upgrade', params)
491
+ }
492
+
450
493
  claimplot(entityId: UInt64Type, targetItemId: UInt16Type, slot: ClusterSlotType): Action {
451
494
  return this.server.action('claimplot', {
452
495
  builder_id: UInt64.from(entityId),
@@ -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
+ })
@@ -104,7 +104,7 @@ export const computeBatteryBankCapacity = (
104
104
  export function entityDisplayName(itemId: number): string {
105
105
  switch (itemId) {
106
106
  case ITEM_SHIP_T1_PACKED:
107
- return 'Ship'
107
+ return 'Roustabout'
108
108
  case ITEM_WAREHOUSE_T1_PACKED:
109
109
  return 'Warehouse'
110
110
  case ITEM_EXTRACTOR_T1_PACKED:
@@ -1,10 +1,7 @@
1
1
  import type {GathererStats} from '../types/capabilities'
2
2
  import type {ServerContract} from '../contracts'
3
- import {
4
- calc_gather_duration,
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
- // massFactor=1 so plan-time cost is conservative; on-chain recomputes with real mass/richness.
23
- const PLAN_ITEM_MASS = GATHER_MASS_DIVISOR
24
- const PLAN_RICHNESS = 1000
19
+ export interface GatherLimpet {
20
+ slot: number
21
+ quantity: number
22
+ durationSeconds: number
23
+ }
25
24
 
26
- // 'max' ceiling; Phase 3 passes a real reserve/capacity cap instead.
27
- const MAX_PLAN_QTY = 10000
25
+ export interface GatherCycle {
26
+ rechargeBefore: boolean
27
+ rechargeSeconds: number
28
+ limpets: GatherLimpet[]
29
+ gatherSeconds: number
30
+ batchOre: number
31
+ }
28
32
 
29
- function gatherEnergyCost(
30
- lane: ServerContract.Types.gatherer_lane,
31
- quantity: number,
32
- stratum: number
33
- ): number {
34
- const stats = lane as unknown as GathererStats
35
- const dur = Number(
36
- calc_gather_duration(stats, PLAN_ITEM_MASS, quantity, stratum, PLAN_RICHNESS)
37
- )
38
- return Number(calc_gather_energy(stats, dur))
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
- function allocateProportional(
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
- export function planParallelGather(
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
- now: Date
68
- ): LanePlanEntry[] {
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
- // Projected energy nets out already-queued/in-flight task costs (contract projected_energy()).
73
- const energy = entity.generator ? Number(projectRemainingAt(entity, now).energy) : Infinity
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 requestedQty = target === 'max' ? MAX_PLAN_QTY : (target as {quantity: number}).quantity
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
- // Ascending by yield so slice(1) sheds the lowest-yield lane first when energy-starved.
78
- let activeLanes = reaching.slice().sort((a, b) => a.yield.toNumber() - b.yield.toNumber())
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
- while (activeLanes.length > 0) {
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 proposed = allocateProportional(laneWeights, requestedQty)
87
-
88
- const totalEnergyCost = proposed.reduce((sum, entry) => {
89
- const lane = activeLanes.find((l) => l.slot_index.toNumber() === entry.slot)!
90
- return sum + gatherEnergyCost(lane, entry.quantity, stratum)
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
- if (totalEnergyCost <= energy) {
94
- return proposed.filter((e) => e.quantity > 0)
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
- return []
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' ? MAX_PLAN_QTY : (target as {quantity: number}).quantity
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(),