@shipload/sdk 1.0.0-next.72 → 1.0.0-next.74

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.
Files changed (47) hide show
  1. package/lib/shipload.d.ts +576 -133
  2. package/lib/shipload.js +6019 -5114
  3. package/lib/shipload.js.map +1 -1
  4. package/lib/shipload.m.js +6002 -5108
  5. package/lib/shipload.m.js.map +1 -1
  6. package/lib/testing.d.ts +194 -52
  7. package/lib/testing.js +767 -468
  8. package/lib/testing.js.map +1 -1
  9. package/lib/testing.m.js +767 -468
  10. package/lib/testing.m.js.map +1 -1
  11. package/package.json +1 -1
  12. package/src/contracts/server.ts +523 -143
  13. package/src/data/catalog.test.ts +81 -1
  14. package/src/data/catalog.ts +6 -4
  15. package/src/data/entities.json +2 -2
  16. package/src/data/metadata.ts +197 -363
  17. package/src/data/task-types.json +184 -0
  18. package/src/entities/makers.ts +2 -2
  19. package/src/index-module.ts +31 -2
  20. package/src/influence/civic-shuttle.test.ts +58 -0
  21. package/src/influence/civic-shuttle.ts +52 -6
  22. package/src/influence/duration.ts +0 -65
  23. package/src/influence/index.ts +4 -5
  24. package/src/influence/influence.test.ts +51 -136
  25. package/src/influence/valuation.ts +75 -0
  26. package/src/managers/actions.ts +49 -10
  27. package/src/managers/balances.test.ts +33 -0
  28. package/src/managers/balances.ts +15 -0
  29. package/src/managers/context.ts +50 -11
  30. package/src/managers/index.ts +14 -0
  31. package/src/managers/jobs.test.ts +82 -1
  32. package/src/managers/jobs.ts +41 -2
  33. package/src/managers/nft.test.ts +173 -1
  34. package/src/managers/nft.ts +35 -2
  35. package/src/managers/shuttle.test.ts +562 -0
  36. package/src/managers/shuttle.ts +397 -0
  37. package/src/resolution/resolve-item.ts +2 -2
  38. package/src/scheduling/availability.ts +10 -0
  39. package/src/scheduling/energy.ts +55 -1
  40. package/src/scheduling/jobs.test.ts +171 -1
  41. package/src/scheduling/jobs.ts +110 -13
  42. package/src/scheduling/projection.ts +2 -1
  43. package/src/scheduling/task-effects.ts +38 -0
  44. package/src/scheduling/unwrap.test.ts +60 -0
  45. package/src/scheduling/unwrap.ts +24 -8
  46. package/src/shipload.ts +23 -4
  47. package/src/types.ts +1 -0
@@ -1,7 +1,11 @@
1
1
  import {describe, expect, it} from 'bun:test'
2
- import type {OrderedTask} from './schedule'
2
+ import {UInt8, UInt32, UInt64} from '@wharfkit/antelope'
3
+ import {ServerContract} from '../contracts'
4
+ import {HoldKind} from '../types'
5
+ import type {OrderedTask, ScheduleData} from './schedule'
3
6
  import {
4
7
  buildJobCancelRoute,
8
+ hostedDropoff,
5
9
  jobCancelRoute,
6
10
  jobCancellationBlockReason,
7
11
  jobCancellable,
@@ -15,6 +19,7 @@ import {
15
19
  } from './jobs'
16
20
 
17
21
  const at = (s: string) => new Date(s)
22
+ const WITH_MODULES = [{item_id: 101, stats: '413333752', quantity: 10, modules: []}] as never
18
23
 
19
24
  const INPUTS = [{item_id: 101, stats: '413333752', quantity: 10}] as never
20
25
  const FULL_INPUT = {
@@ -191,6 +196,28 @@ describe('jobStatus', () => {
191
196
  it('reads an undeposited row as dropping off when no schedule is given', () => {
192
197
  expect(jobStatus(inFlight, at('2026-07-26T09:00:00Z'))).toBe('dropping')
193
198
  })
199
+ it('finds the Drop-off of a booking-time undeposited job from its inputs-only cargo', () => {
200
+ const input = {item_id: 1, stats: '0', quantity: 5, modules: [], entity_id: undefined}
201
+ const {inputs} = splitJobCargo([input], false, 1)
202
+ const dropoff = task({
203
+ type: 21,
204
+ building: 7,
205
+ cargo: [input],
206
+ startsAt: at('2026-07-26T00:00:00Z'),
207
+ completesAt: at('2026-07-26T00:00:01Z'),
208
+ })
209
+ expect(
210
+ jobDropoffTask(
211
+ {
212
+ startsAt: new Date(0),
213
+ completesAt: new Date(0),
214
+ building: 7,
215
+ inputs: inputs as never,
216
+ },
217
+ [dropoff]
218
+ )
219
+ ).toBe(dropoff)
220
+ })
194
221
  it('labels every phase', () => {
195
222
  expect(jobStatusLabel('booked')).toBe('Booked')
196
223
  expect(jobStatusLabel('dropping')).toBe('Dropping off')
@@ -262,6 +289,37 @@ describe('jobCancelRoute', () => {
262
289
  it('cancels a Queued job through cancelcraft', () => {
263
290
  expect(jobCancelRoute(job, at('2026-07-26T09:59:59Z'))).toEqual({kind: 'craft', jobId: 7})
264
291
  })
292
+ it('routes a bay-hosted drop-off cancel through cancelcivic when the task is the lane tail and not yet completed', () => {
293
+ const route = jobCancelRoute(inFlight, at('2026-07-26T09:00:00Z'), [], {
294
+ hosted: {hostId: 90000, laneKey: 1, taskIndex: 0, civic: true},
295
+ hostedTask: dropoff,
296
+ hostLaneLength: 1,
297
+ })
298
+ expect(route).toEqual({kind: 'civic', buildingId: 90000, laneKey: 1, fromId: 5})
299
+ })
300
+ it('refuses a bay-hosted civic cancel once the hosted task has completed', () => {
301
+ const route = jobCancelRoute(inFlight, at('2026-07-26T10:00:00Z'), [], {
302
+ hosted: {hostId: 90000, laneKey: 1, taskIndex: 0, civic: true},
303
+ hostedTask: dropoff,
304
+ hostLaneLength: 1,
305
+ })
306
+ expect(route).toBeNull()
307
+ })
308
+ it('refuses a bay-hosted civic cancel when the hosted task is not the lane tail', () => {
309
+ const route = jobCancelRoute(inFlight, at('2026-07-26T09:00:00Z'), [], {
310
+ hosted: {hostId: 90000, laneKey: 1, taskIndex: 0, civic: true},
311
+ hostedTask: dropoff,
312
+ hostLaneLength: 2,
313
+ })
314
+ expect(route).toBeNull()
315
+ })
316
+ it('routes a third-party-hosted drop-off cancel through the carrier ship', () => {
317
+ const route = jobCancelRoute(inFlight, at('2026-07-26T09:00:00Z'), [], {
318
+ hosted: {hostId: 8, laneKey: 2, taskIndex: 0, civic: false},
319
+ hostLaneLength: 3,
320
+ })
321
+ expect(route).toEqual({kind: 'dropoff', shipId: 8, laneKey: 2, count: 3})
322
+ })
265
323
  it('refuses only deposited positive-quantity output-only legacy jobs', () => {
266
324
  const legacy = {...job, inputs: []}
267
325
  expect(jobCancellationBlockReason(legacy)).toBe('legacy-inputs-unavailable')
@@ -412,6 +470,15 @@ describe('splitJobCargo', () => {
412
470
  expect(splitJobCargo([], true, 1)).toEqual({output: null, inputs: []})
413
471
  expect(splitJobCargo([], false, 1)).toEqual({output: null, inputs: []})
414
472
  })
473
+ it('reads an undeposited row whose input is booked as more than one stack per recipe slot', () => {
474
+ const stackA1 = {item_id: 1, quantity: 2}
475
+ const stackA2 = {item_id: 1, quantity: 1}
476
+ const stackB = {item_id: 2, quantity: 4}
477
+ const cargo = [stackA1, stackA2, stackB] as unknown as Parameters<typeof splitJobCargo>[0]
478
+ const {output, inputs} = splitJobCargo(cargo, false, 1)
479
+ expect(output).toBeNull()
480
+ expect(inputs).toEqual([stackA1, stackA2, stackB] as never)
481
+ })
415
482
  })
416
483
 
417
484
  describe('workshopAvailability', () => {
@@ -446,3 +513,106 @@ describe('workshopAvailability', () => {
446
513
  expect(workshopAvailability(jobs, 1, now)).toEqual({open: 1, nextOpeningAt: null})
447
514
  })
448
515
  })
516
+
517
+ describe('hostedDropoff', () => {
518
+ function hostWithDeposit(
519
+ entityType: string,
520
+ building: number,
521
+ holdId: number,
522
+ owner = 'nex.shipload'
523
+ ): ScheduleData & {owner: string} {
524
+ return {
525
+ owner,
526
+ lanes: [
527
+ ServerContract.Types.lane.from({
528
+ lane_key: UInt8.from(1),
529
+ schedule: {
530
+ started: '2026-07-26T09:00:00.000',
531
+ tasks: [
532
+ {
533
+ type: UInt8.from(21),
534
+ duration: UInt32.from(600),
535
+ cancelable: UInt8.from(2),
536
+ cargo: WITH_MODULES,
537
+ couplings: [
538
+ {
539
+ counterpart: {entity_type: 'ship', entity_id: 5},
540
+ hold: holdId,
541
+ kind: HoldKind.PULL,
542
+ },
543
+ ],
544
+ subject: {entity_type: entityType, entity_id: building},
545
+ },
546
+ ],
547
+ },
548
+ }),
549
+ ],
550
+ }
551
+ }
552
+
553
+ function sourceWithHold(hostId: number, hostType: string, holdId: number): ScheduleData {
554
+ return {
555
+ holds: [
556
+ ServerContract.Types.hold.from({
557
+ id: holdId,
558
+ kind: HoldKind.PULL,
559
+ counterpart: {entity_type: hostType, entity_id: hostId},
560
+ until: '2026-07-26T09:50:00.000',
561
+ incoming_mass: 0,
562
+ }),
563
+ ],
564
+ }
565
+ }
566
+
567
+ const job = {
568
+ building: 42,
569
+ inputs: WITH_MODULES,
570
+ startsAt: at('2026-07-26T10:00:00Z'),
571
+ completesAt: at('2026-07-26T11:00:00Z'),
572
+ }
573
+
574
+ it('finds the carrier hosting a bay-hosted Drop-off and flags it civic', () => {
575
+ const host = hostWithDeposit('depot', 42, 3, 'nex.shipload')
576
+ const source = sourceWithHold(90000, 'depot', 3)
577
+ const lookup = (id: string) => (id === '90000' ? host : undefined)
578
+ const result = hostedDropoff(source, lookup, job, 'nex.shipload')
579
+ expect(result).toMatchObject({hostId: 90000, laneKey: 1, taskIndex: 0, civic: true})
580
+ })
581
+
582
+ it('does not flag a player-owned Depot civic', () => {
583
+ const host = hostWithDeposit('depot', 42, 3, 'alice')
584
+ const source = sourceWithHold(90000, 'depot', 3)
585
+ const lookup = (id: string) => (id === '90000' ? host : undefined)
586
+ const result = hostedDropoff(source, lookup, job, 'nex.shipload')
587
+ expect(result).toMatchObject({hostId: 90000, laneKey: 1, taskIndex: 0, civic: false})
588
+ })
589
+
590
+ it('finds a third-party carrier and does not flag it civic', () => {
591
+ const host = hostWithDeposit('ship', 42, 3, 'alice')
592
+ const source = sourceWithHold(8, 'ship', 3)
593
+ const lookup = (id: string) => (id === '8' ? host : undefined)
594
+ const result = hostedDropoff(source, lookup, job, 'nex.shipload')
595
+ expect(result).toMatchObject({hostId: 8, laneKey: 1, taskIndex: 0, civic: false})
596
+ })
597
+
598
+ it('returns undefined when no PULL hold names a matching host task', () => {
599
+ expect(hostedDropoff({holds: []}, () => undefined, job)).toBeUndefined()
600
+ const host = hostWithDeposit('depot', 99, 3)
601
+ const source = sourceWithHold(90000, 'depot', 3)
602
+ const lookup = (id: string) => (id === '90000' ? host : undefined)
603
+ expect(hostedDropoff(source, lookup, job)).toBeUndefined()
604
+ })
605
+
606
+ it('does not match a Drop-off whose arrival differs from the job', () => {
607
+ const host = hostWithDeposit('depot', 42, 3, 'nex.shipload')
608
+ const source = sourceWithHold(90000, 'depot', 3)
609
+ const lookup = (id: string) => (id === '90000' ? host : undefined)
610
+ const result = hostedDropoff(
611
+ source,
612
+ lookup,
613
+ {...job, arrivesAt: at('2026-07-26T12:00:00Z')},
614
+ 'nex.shipload'
615
+ )
616
+ expect(result).toBeUndefined()
617
+ })
618
+ })
@@ -1,6 +1,8 @@
1
+ import type {NameType} from '@wharfkit/antelope'
1
2
  import type {ServerContract} from '../contracts'
2
- import {TaskType} from '../types'
3
- import type {OrderedTask} from './schedule'
3
+ import {isCivicEntity} from '../influence/civic'
4
+ import {HoldKind, TaskType} from '../types'
5
+ import {orderedTasks, type OrderedTask, type ScheduleData} from './schedule'
4
6
 
5
7
  type CargoItem = ServerContract.Types.cargo_item
6
8
 
@@ -180,13 +182,31 @@ export function jobDropoffTask(
180
182
  return matches?.length === 1 ? matches[0] : undefined
181
183
  }
182
184
 
185
+ export interface HostedLeg {
186
+ hostId: number
187
+ laneKey: number
188
+ taskIndex: number
189
+ civic: boolean
190
+ }
191
+
192
+ export interface JobRouteOptions {
193
+ hosted?: HostedLeg
194
+ hostedTask?: OrderedTask
195
+ hostLaneLength?: number
196
+ }
197
+
183
198
  // Without the ship's schedule an undeposited row reads as Dropping off: the transfer usually starts at once.
184
199
  export function jobStatus(
185
200
  job: JobStatusInput,
186
201
  now: Date,
187
- tasks?: readonly OrderedTask[]
202
+ tasks?: readonly OrderedTask[],
203
+ options?: JobRouteOptions
188
204
  ): JobStatus {
189
205
  if (job.deposited === false) {
206
+ if (options?.hosted) {
207
+ const start = options.hostedTask?.startsAt
208
+ return start && now < start ? 'booked' : 'dropping'
209
+ }
190
210
  const dropoff = jobDropoffTask(job, tasks)
191
211
  return dropoff && now < dropoff.startsAt ? 'booked' : 'dropping'
192
212
  }
@@ -231,18 +251,37 @@ export type JobCancelRoute =
231
251
  | {kind: 'dropoff'; shipId: number; laneKey: number; count: number}
232
252
  | {kind: 'craft'; jobId: number}
233
253
  | {kind: 'build'; jobId: number}
254
+ | {kind: 'civic'; buildingId: number; laneKey: number; fromId: number}
234
255
 
235
256
  // The ship's cancel pops from the lane tail, so the count reaches from the Drop-off through every task queued behind it.
236
257
  function cancelRoute(
237
258
  job: JobStatusInput & {id: number; shipId?: number},
238
259
  queued: 'craft' | 'build',
239
260
  now: Date,
240
- tasks?: readonly OrderedTask[]
261
+ tasks?: readonly OrderedTask[],
262
+ options?: JobRouteOptions
241
263
  ): JobCancelRoute | null {
242
- if (!jobCancellable(jobStatus(job, now, tasks))) return null
264
+ if (!jobCancellable(jobStatus(job, now, tasks, options))) return null
243
265
  if (queued === 'craft' && jobCancellationBlockReason(job, tasks)) return null
244
266
  if (job.deposited !== false) return {kind: queued, jobId: job.id}
245
- if (job.shipId === undefined || !tasks) return null
267
+ if (job.shipId === undefined) return null
268
+ if (options?.hosted) {
269
+ const {hostId, laneKey, taskIndex, civic} = options.hosted
270
+ const laneLength = options.hostLaneLength ?? taskIndex + 1
271
+ if (civic) {
272
+ const isTail = taskIndex === laneLength - 1
273
+ const notCompleted = options.hostedTask ? options.hostedTask.completesAt > now : false
274
+ if (!isTail || !notCompleted) return null
275
+ return {kind: 'civic', buildingId: hostId, laneKey, fromId: job.shipId}
276
+ }
277
+ return {
278
+ kind: 'dropoff',
279
+ shipId: hostId,
280
+ laneKey,
281
+ count: laneLength - taskIndex,
282
+ }
283
+ }
284
+ if (!tasks) return null
246
285
  const dropoff = jobDropoffTask(job, tasks)
247
286
  if (!dropoff || dropoff.completesAt <= now) return null
248
287
  const laneLength = tasks.filter((t) => t.laneKey === dropoff.laneKey).length
@@ -257,18 +296,59 @@ function cancelRoute(
257
296
  export function jobCancelRoute(
258
297
  job: JobStatusInput & {id: number; shipId?: number},
259
298
  now: Date,
260
- tasks?: readonly OrderedTask[]
299
+ tasks?: readonly OrderedTask[],
300
+ options?: JobRouteOptions
261
301
  ): JobCancelRoute | null {
262
- return cancelRoute(job, 'craft', now, tasks)
302
+ return cancelRoute(job, 'craft', now, tasks, options)
263
303
  }
264
304
 
265
305
  // A Build Job's Drop-off rides on the upgrade target itself, so the target plays the ship's part.
266
306
  export function buildJobCancelRoute(
267
307
  job: JobStatusInput & {id: number; targetId?: number},
268
308
  now: Date,
269
- tasks?: readonly OrderedTask[]
309
+ tasks?: readonly OrderedTask[],
310
+ options?: JobRouteOptions
270
311
  ): JobCancelRoute | null {
271
- return cancelRoute({...job, shipId: job.targetId}, 'build', now, tasks)
312
+ return cancelRoute({...job, shipId: job.targetId}, 'build', now, tasks, options)
313
+ }
314
+
315
+ export interface HostedDropoffEntity extends ScheduleData {
316
+ owner?: NameType
317
+ }
318
+
319
+ // The source entity's HOLD_PULL names the carrier; the carrier's schedule carries the Drop-off task.
320
+ export function hostedDropoff(
321
+ source: ScheduleData,
322
+ lookup: (id: string) => HostedDropoffEntity | undefined,
323
+ job: JobStatusInput,
324
+ civicOwner?: NameType
325
+ ): (HostedLeg & {task: OrderedTask}) | undefined {
326
+ for (const hold of source.holds ?? []) {
327
+ if (hold.kind.toNumber() !== HoldKind.PULL) continue
328
+ const host = lookup(hold.counterpart.entity_id.toString())
329
+ if (!host) continue
330
+ for (const entry of orderedTasks(host)) {
331
+ if (Number(entry.task.type) !== TaskType.CIVIC_DEPOSIT) continue
332
+ if (!entry.task.couplings.some((c) => c.hold.equals(hold.id))) continue
333
+ if (!matchesJob(entry, job)) continue
334
+ if (
335
+ job.arrivesAt !== undefined &&
336
+ entry.completesAt.getTime() !== job.arrivesAt.getTime()
337
+ )
338
+ continue
339
+ return {
340
+ hostId: Number(hold.counterpart.entity_id),
341
+ laneKey: entry.laneKey,
342
+ taskIndex: entry.taskIndex,
343
+ civic:
344
+ civicOwner !== undefined &&
345
+ host.owner !== undefined &&
346
+ isCivicEntity({owner: host.owner}, civicOwner),
347
+ task: entry,
348
+ }
349
+ }
350
+ }
351
+ return undefined
272
352
  }
273
353
 
274
354
  export interface PickupInFlight {
@@ -312,15 +392,32 @@ export function jobDeposited(value: unknown): boolean {
312
392
  return Boolean(value)
313
393
  }
314
394
 
315
- // A deposited row's output is the cargo tail; a cancelled row (quantity 0) holds only its inputs.
395
+ // A deposited row's cargo is its inputs plus the output tail; an undeposited row carries inputs only.
316
396
  export function splitJobCargo<T>(
317
397
  cargo: readonly T[],
318
398
  deposited: boolean,
319
399
  quantity: number
320
400
  ): {output: T | null; inputs: T[]} {
321
401
  if (cargo.length === 0) return {output: null, inputs: []}
322
- if (!deposited || quantity === 0) return {output: null, inputs: [...cargo]}
323
- return {output: cargo[cargo.length - 1], inputs: cargo.slice(0, -1)}
402
+ if (deposited && quantity > 0)
403
+ return {output: cargo[cargo.length - 1], inputs: cargo.slice(0, -1)}
404
+ return {output: null, inputs: [...cargo]}
405
+ }
406
+
407
+ export interface BuildJob {
408
+ id: number
409
+ building: number
410
+ socket: number
411
+ targetId: number
412
+ owner: string
413
+ coords: {x: number; y: number}
414
+ startsAt: Date
415
+ completesAt: Date
416
+ arrivesAt: Date
417
+ targetItemId: number
418
+ status: JobStatus
419
+ deposited: boolean
420
+ inputs: CargoItem[]
324
421
  }
325
422
 
326
423
  export interface OwnedJob {
@@ -30,7 +30,7 @@ import {
30
30
  stackToCargoItem,
31
31
  } from '../capabilities/storage'
32
32
  import {hydrateEntityLanes} from '../entity/hydrate'
33
- import {craftCargoOwnership} from './availability'
33
+ import {craftCargoOwnership, isHostedCivicLeg} from './availability'
34
34
  import * as schedule from './schedule'
35
35
  import type {ScheduleData} from './schedule'
36
36
 
@@ -394,6 +394,7 @@ function applyCraftTask(projected: ProjectedEntity, task: ServerContract.Types.t
394
394
  }
395
395
 
396
396
  function applyTask(projected: ProjectedEntity, task: ServerContract.Types.task): void {
397
+ if (isHostedCivicLeg(task)) return
397
398
  switch (task.type.toNumber()) {
398
399
  case TaskType.RECHARGE:
399
400
  applyRechargeTask(projected, task, {complete: true})
@@ -32,3 +32,41 @@ export const TASK_CARGO_EFFECTS: ReadonlyMap<number, TaskCargoRule> = new Map<
32
32
  export function taskCargoRule(taskType: number): TaskCargoRule | undefined {
33
33
  return TASK_CARGO_EFFECTS.get(taskType)
34
34
  }
35
+
36
+ // Transcribed from TASK_TRAITS in contracts/src/server/include/server/task_traits.hpp.
37
+ export type TaskEnergyEffect = 'none' | 'fills' | 'draws' | 'zeroes'
38
+
39
+ export const TASK_ENERGY_EFFECTS: ReadonlyMap<number, TaskEnergyEffect> = new Map<
40
+ number,
41
+ TaskEnergyEffect
42
+ >([
43
+ [TaskType.IDLE, 'none'],
44
+ [TaskType.TRAVEL, 'draws'],
45
+ [TaskType.RECHARGE, 'fills'],
46
+ [TaskType.LOAD, 'none'],
47
+ [TaskType.UNLOAD, 'none'],
48
+ [TaskType.GATHER, 'draws'],
49
+ [TaskType.WARP, 'zeroes'],
50
+ [TaskType.CRAFT, 'draws'],
51
+ [TaskType.TRANSIT, 'none'],
52
+ [TaskType.UNWRAP, 'none'],
53
+ [TaskType.UNDEPLOY, 'none'],
54
+ [TaskType.LAUNCH, 'none'],
55
+ [TaskType.DEMOLISH, 'none'],
56
+ [TaskType.BUILDPLOT, 'draws'],
57
+ [TaskType.CHARGE, 'draws'],
58
+ [TaskType.UPGRADE, 'draws'],
59
+ [TaskType.SHUTTLE, 'none'],
60
+ [TaskType.CONTRIBUTE, 'none'],
61
+ [TaskType.CIVIC_DEPOSIT, 'draws'],
62
+ [TaskType.CIVIC_WITHDRAW, 'none'],
63
+ ])
64
+
65
+ export function taskEnergyEffect(taskType: number): TaskEnergyEffect {
66
+ return TASK_ENERGY_EFFECTS.get(taskType) ?? 'none'
67
+ }
68
+
69
+ // Only TRAVEL is both 'draws' and mobility-trait; WARP's 'zeroes' branch never checks this.
70
+ export function taskEnergyDrawNeedsCoordinates(taskType: number): boolean {
71
+ return taskType === TaskType.TRAVEL
72
+ }
@@ -5,8 +5,14 @@ import {
5
5
  unwrapLoadDuration,
6
6
  estimateUnwrapDuration,
7
7
  incomingHoldMass,
8
+ projectCargomass,
8
9
  projectedPeakCargomass,
9
10
  } from './unwrap'
11
+ import {TimePoint, UInt8, UInt16, UInt32, UInt64} from '@wharfkit/antelope'
12
+ import {ServerContract} from '../contracts'
13
+ import {getItem} from '../data/catalog'
14
+ import {ITEM_BEAM} from '../data/item-ids'
15
+ import {TaskType} from '../types'
10
16
 
11
17
  describe('unwrap duration mirror', () => {
12
18
  test('derivedLoaders aggregates lanes like derived_loaders()', () => {
@@ -84,3 +90,57 @@ test('projectedPeakCargomass tracks the running peak from cargomass', () => {
84
90
  // No pending tasks: peak = base + candidate add.
85
91
  expect(projectedPeakCargomass(entity, new Date(0), 500)).toBe(1500)
86
92
  })
93
+
94
+ const DEPOSIT_START = new Date('2026-09-20T07:48:37.000Z')
95
+ const BEAM_QTY = 2_500
96
+ const BEAM_MASS = getItem(ITEM_BEAM).mass
97
+
98
+ function unloadingShip(projectedAtMs?: number) {
99
+ const cargo = ServerContract.Types.cargo_item.from({
100
+ item_id: UInt16.from(ITEM_BEAM),
101
+ quantity: UInt32.from(BEAM_QTY),
102
+ stats: UInt64.from(0),
103
+ modules: [],
104
+ })
105
+ const lane = ServerContract.Types.lane.from({
106
+ lane_key: UInt8.from(0),
107
+ schedule: ServerContract.Types.schedule.from({
108
+ started: TimePoint.fromMilliseconds(DEPOSIT_START.getTime()),
109
+ tasks: [
110
+ ServerContract.Types.task.from({
111
+ type: UInt8.from(TaskType.CIVIC_DEPOSIT),
112
+ duration: UInt32.from(1_309),
113
+ cancelable: UInt8.from(2),
114
+ cargo: [cargo],
115
+ couplings: [],
116
+ }),
117
+ ],
118
+ }),
119
+ })
120
+ return {
121
+ cargomass: BEAM_QTY * BEAM_MASS,
122
+ lanes: [lane],
123
+ holds: [],
124
+ projected_at:
125
+ projectedAtMs === undefined ? undefined : TimePoint.fromMilliseconds(projectedAtMs),
126
+ }
127
+ }
128
+
129
+ const UNLOAD_ENDS = new Date(DEPOSIT_START.getTime() + 1_309_000)
130
+ const AFTER_UNLOAD = new Date(UNLOAD_ENDS.getTime() + 60_000)
131
+
132
+ test('projectCargomass reports the mass held at the candidate and the peak around it', () => {
133
+ const plan = projectCargomass(unloadingShip(), AFTER_UNLOAD, 400 * BEAM_MASS)
134
+ // The queued unload empties the hold before the candidate lands.
135
+ expect(plan.used).toBe(400 * BEAM_MASS)
136
+ expect(plan.peak).toBe(BEAM_QTY * BEAM_MASS)
137
+ })
138
+
139
+ test('a task already applied to a projected row is not replayed', () => {
140
+ const applied = unloadingShip(UNLOAD_ENDS.getTime())
141
+ applied.cargomass = 0
142
+ const plan = projectCargomass(applied, AFTER_UNLOAD, 400 * BEAM_MASS)
143
+ expect(plan.used).toBe(400 * BEAM_MASS)
144
+ expect(plan.peak).toBe(400 * BEAM_MASS)
145
+ expect(projectedPeakCargomass(applied, AFTER_UNLOAD, 400 * BEAM_MASS)).toBe(plan.peak)
146
+ })
@@ -133,28 +133,44 @@ function cargoListMass(items: CargoItem[]): number {
133
133
  return m
134
134
  }
135
135
 
136
- export function projectedPeakCargomass(
137
- entity: sched.ScheduleData & {cargomass: number | {toNumber(): number}},
136
+ export interface CargomassProjection {
137
+ used: number
138
+ peak: number
139
+ }
140
+
141
+ export function projectCargomass(
142
+ entity: sched.AnchoredScheduleData & {cargomass: number | {toNumber(): number}},
138
143
  at: Date,
139
144
  addMass: number,
140
145
  removeMass = 0
141
- ): number {
142
- const events: {t: number; delta: number}[] = []
143
- for (const ordered of sched.orderedTasks(entity)) {
146
+ ): CargomassProjection {
147
+ const events: {t: number; delta: number; candidate: boolean}[] = []
148
+ for (const ordered of sched.unappliedTasks(entity)) {
144
149
  const eff = taskCargoEffect(ordered.task)
145
150
  const delta = cargoListMass(eff.added) - cargoListMass(eff.removed)
146
- events.push({t: ordered.completesAt.getTime(), delta})
151
+ events.push({t: ordered.completesAt.getTime(), delta, candidate: false})
147
152
  }
148
- events.push({t: at.getTime(), delta: addMass - removeMass})
153
+ events.push({t: at.getTime(), delta: addMass - removeMass, candidate: true})
149
154
  events.sort((a, b) => (a.t !== b.t ? a.t - b.t : b.delta - a.delta))
150
155
  let running = Number(entity.cargomass)
151
156
  let peak = running
157
+ let used = running
152
158
  for (const e of events) {
153
159
  running += e.delta
154
160
  if (running < 0) running = 0
155
161
  if (running > peak) peak = running
162
+ if (e.candidate) used = running
156
163
  }
157
- return Math.min(peak, 0xffff_ffff)
164
+ return {used: Math.min(used, 0xffff_ffff), peak: Math.min(peak, 0xffff_ffff)}
165
+ }
166
+
167
+ export function projectedPeakCargomass(
168
+ entity: sched.AnchoredScheduleData & {cargomass: number | {toNumber(): number}},
169
+ at: Date,
170
+ addMass: number,
171
+ removeMass = 0
172
+ ): number {
173
+ return projectCargomass(entity, at, addMass, removeMass).peak
158
174
  }
159
175
 
160
176
  export function receiveFits(
package/src/shipload.ts CHANGED
@@ -15,6 +15,7 @@ import type {NftManager} from './managers/nft'
15
15
  import type {BalancesManager} from './managers/balances'
16
16
  import type {FundManager} from './managers/fund'
17
17
  import type {JobsManager} from './managers/jobs'
18
+ import type {ShuttleManager} from './managers/shuttle'
18
19
  import type {InfluenceManager} from './managers/influence'
19
20
  import type {SubscriptionsManager} from './subscriptions/manager'
20
21
  import type {GameState} from './entities/gamestate'
@@ -23,6 +24,9 @@ interface ShiploadOptions {
23
24
  platformContractName?: string
24
25
  serverContractName?: string
25
26
  fundContractName?: string
27
+ platformContractAccount?: string
28
+ serverContractAccount?: string
29
+ fundContractAccount?: string
26
30
  client?: APIClient
27
31
  subscriptionsUrl?: string
28
32
  atomicAssetsAccount?: string
@@ -43,13 +47,24 @@ export class Shipload {
43
47
 
44
48
  const platform = platformContract
45
49
  ? platformContract
46
- : new PlatformContract.Contract({client: apiClient})
50
+ : new PlatformContract.Contract({
51
+ client: apiClient,
52
+ account: constructorOptions?.platformContractAccount,
53
+ })
47
54
 
48
55
  const server = serverContract
49
56
  ? serverContract
50
- : new ServerContract.Contract({client: apiClient})
51
-
52
- const fund = fundContract ? fundContract : new FundContract.Contract({client: apiClient})
57
+ : new ServerContract.Contract({
58
+ client: apiClient,
59
+ account: constructorOptions?.serverContractAccount,
60
+ })
61
+
62
+ const fund = fundContract
63
+ ? fundContract
64
+ : new FundContract.Contract({
65
+ client: apiClient,
66
+ account: constructorOptions?.fundContractAccount,
67
+ })
53
68
 
54
69
  this._context = new GameContext(
55
70
  apiClient,
@@ -167,6 +182,10 @@ export class Shipload {
167
182
  return this._context.jobs
168
183
  }
169
184
 
185
+ get shuttle(): ShuttleManager {
186
+ return this._context.shuttle
187
+ }
188
+
170
189
  get influence(): InfluenceManager {
171
190
  return this._context.influence
172
191
  }
package/src/types.ts CHANGED
@@ -208,6 +208,7 @@ export const CATEGORY_LABELS: Record<ResourceCategory, string> = {
208
208
 
209
209
  export interface Item {
210
210
  id: number
211
+ family: string
211
212
  name: string
212
213
  description: string
213
214
  color: string