@shipload/sdk 1.0.0-next.55 → 1.0.0-next.57

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@shipload/sdk",
3
3
  "description": "SDKs for Shipload",
4
- "version": "1.0.0-next.55",
4
+ "version": "1.0.0-next.57",
5
5
  "homepage": "https://github.com/shipload/toolkit/tree/master/packages/sdk",
6
6
  "repository": {
7
7
  "type": "git",
@@ -0,0 +1,82 @@
1
+ import {describe, expect, test} from 'bun:test'
2
+ import {ServerContract} from '../contracts'
3
+ import {UInt16, UInt32, UInt64} from '@wharfkit/antelope'
4
+ import {
5
+ ITEM_ENGINE_T1,
6
+ ITEM_GENERATOR_T1,
7
+ ITEM_CRAFTER_T1,
8
+ ITEM_PLASMA_CELL,
9
+ ITEM_SHIP_T1_PACKED,
10
+ } from '../data/item-ids'
11
+ import {getRecipe} from '../data/recipes-runtime'
12
+ import {encodeStats} from '../derivation/crafting'
13
+ import {rollupCrafter} from '../derivation/rollups'
14
+ import {makeEntity} from '../entities/makers'
15
+ import type {IncomingSource} from '../scheduling/availability'
16
+ import {maxCraftable} from './craftable'
17
+
18
+ const NOW = new Date('2026-06-11T17:06:40.000Z')
19
+
20
+ function ship(cargo: ServerContract.Types.cargo_item[] = []) {
21
+ return makeEntity(ITEM_SHIP_T1_PACKED, {
22
+ id: 99,
23
+ owner: 'tester.gm',
24
+ name: 'Craft Tester',
25
+ coordinates: {x: 0, y: 0},
26
+ hullmass: 200_000,
27
+ capacity: 8_000_000,
28
+ cargomass: 0,
29
+ energy: 500_000,
30
+ modules: [
31
+ {itemId: ITEM_GENERATOR_T1, stats: encodeStats([999, 999])},
32
+ {itemId: ITEM_CRAFTER_T1, stats: encodeStats([999, 999])},
33
+ ],
34
+ cargo,
35
+ })
36
+ }
37
+
38
+ function crafterSpeed(entity: ReturnType<typeof ship>): number {
39
+ return rollupCrafter(entity.crafter_lanes)!.speed.toNumber()
40
+ }
41
+
42
+ function plasmaCell(quantity: number) {
43
+ return ServerContract.Types.cargo_item.from({
44
+ item_id: UInt16.from(ITEM_PLASMA_CELL),
45
+ quantity: UInt32.from(quantity),
46
+ stats: UInt64.from(0),
47
+ modules: [],
48
+ })
49
+ }
50
+
51
+ describe('maxCraftable — incoming', () => {
52
+ test('on-hand-only baseline stays 0 for an empty-cargo entity', () => {
53
+ const recipe = getRecipe(ITEM_ENGINE_T1)!
54
+ const entity = ship([])
55
+ expect(maxCraftable(entity, recipe, crafterSpeed(entity), NOW)).toBe(0)
56
+ })
57
+
58
+ test('incoming source alone covers the recipe inputs', () => {
59
+ const recipe = getRecipe(ITEM_ENGINE_T1)!
60
+ const entity = ship([])
61
+ const incoming: IncomingSource[] = [
62
+ {holdId: '1', until: new Date(NOW.getTime() - 1000), items: [plasmaCell(1800)]},
63
+ ]
64
+
65
+ expect(maxCraftable(entity, recipe, crafterSpeed(entity), NOW)).toBe(0)
66
+ expect(maxCraftable(entity, recipe, crafterSpeed(entity), NOW, incoming)).toBe(3)
67
+ })
68
+
69
+ test('an incoming source arriving well after craft completion still resolves (eventually craftable)', () => {
70
+ const recipe = getRecipe(ITEM_ENGINE_T1)!
71
+ const entity = ship([])
72
+ const farFuture: IncomingSource[] = [
73
+ {
74
+ holdId: '1',
75
+ until: new Date(NOW.getTime() + 1_000_000_000),
76
+ items: [plasmaCell(1800)],
77
+ },
78
+ ]
79
+
80
+ expect(maxCraftable(entity, recipe, crafterSpeed(entity), NOW, farFuture)).toBe(3)
81
+ })
82
+ })
@@ -1,8 +1,14 @@
1
1
  import type {ServerContract} from '../contracts'
2
2
  import {getItem} from '../data/catalog'
3
3
  import type {Recipe} from '../data/recipes-runtime'
4
- import {availableForItem, cargoReadyAt, projectedCargoAvailableAt} from '../scheduling/availability'
4
+ import {
5
+ availableForItem,
6
+ projectedCargoAvailableAt,
7
+ taskCargoEffect,
8
+ type IncomingSource,
9
+ } from '../scheduling/availability'
5
10
  import {candidateLaneCompletesAt, workerLaneKey} from '../scheduling/lanes'
11
+ import * as schedule from '../scheduling/schedule'
6
12
  import type {ScheduleData} from '../scheduling/schedule'
7
13
  import {calc_craft_duration} from './crafting'
8
14
 
@@ -14,11 +20,35 @@ export interface CraftableEntity extends ScheduleData {
14
20
  modules: ModuleEntry[]
15
21
  }
16
22
 
23
+ // Recipe inputs aren't stat-pinned (any variant counts, mirroring availableForItem's item-id aggregation).
24
+ function itemReadyAt(
25
+ entity: CraftableEntity,
26
+ itemIds: readonly number[],
27
+ incoming: readonly IncomingSource[]
28
+ ): Date {
29
+ let readyMs = 0
30
+ for (const ordered of schedule.orderedTasks(entity)) {
31
+ for (const item of taskCargoEffect(ordered.task).added) {
32
+ if (itemIds.includes(item.item_id.toNumber())) {
33
+ readyMs = Math.max(readyMs, ordered.completesAt.getTime())
34
+ break
35
+ }
36
+ }
37
+ }
38
+ for (const src of incoming) {
39
+ if (src.items.some((item) => itemIds.includes(item.item_id.toNumber()))) {
40
+ readyMs = Math.max(readyMs, src.until.getTime())
41
+ }
42
+ }
43
+ return new Date(readyMs)
44
+ }
45
+
17
46
  export function maxCraftable(
18
47
  entity: CraftableEntity,
19
48
  recipe: Recipe,
20
49
  crafterSpeed: number,
21
- now: Date
50
+ now: Date,
51
+ incoming: readonly IncomingSource[] = []
22
52
  ): number {
23
53
  if (recipe.inputs.length === 0) return 0
24
54
 
@@ -30,12 +60,13 @@ export function maxCraftable(
30
60
  const crafterLane = workerLaneKey(entity.modules, 'crafter', entity.lanes ?? [])
31
61
  const naiveCompletesAt = candidateLaneCompletesAt(entity, crafterLane, perUnitDuration, now)
32
62
  const laneStartMs = naiveCompletesAt.getTime() - perUnitDuration * 1000
33
- const readyMs = cargoReadyAt(
63
+ const readyMs = itemReadyAt(
34
64
  entity,
35
- recipe.inputs.map((input) => input.itemId)
65
+ recipe.inputs.map((input) => input.itemId),
66
+ incoming
36
67
  ).getTime()
37
68
  const completesAt = new Date(Math.max(laneStartMs, readyMs) + perUnitDuration * 1000)
38
- const availability = projectedCargoAvailableAt(entity, completesAt)
69
+ const availability = projectedCargoAvailableAt(entity, completesAt, incoming)
39
70
 
40
71
  let maxUnits: bigint | undefined
41
72
  for (const input of recipe.inputs) {
@@ -0,0 +1,17 @@
1
+ import {describe, expect, test} from 'bun:test'
2
+ import {usedInputStatKeys} from './crafting'
3
+ import {ITEM_BUILDER_T1} from '../data/item-ids'
4
+
5
+ describe('usedInputStatKeys', () => {
6
+ test('maps each input to the stat keys the blend actually consumes', () => {
7
+ // Ceramic.hardness feeds the output's resonance slot; a name match would miss it.
8
+ expect(usedInputStatKeys(ITEM_BUILDER_T1)).toEqual([
9
+ ['resonance'],
10
+ ['hardness', 'fineness'],
11
+ ])
12
+ })
13
+
14
+ test('returns an empty array for an unknown recipe', () => {
15
+ expect(usedInputStatKeys(999999)).toEqual([])
16
+ })
17
+ })
@@ -66,6 +66,24 @@ function keyForRecipeInputStat(recipe: Recipe, inputIndex: number, statIndex: nu
66
66
  return innerKeys[statIndex] ?? ''
67
67
  }
68
68
 
69
+ export function usedInputStatKeys(outputItemId: number): string[][] {
70
+ const recipe = getRecipe(outputItemId)
71
+ if (!recipe) return []
72
+ const usedIdx = recipe.inputs.map(() => new Set<number>())
73
+ for (const slot of recipe.statSlots) {
74
+ for (const src of slot.sources) {
75
+ usedIdx[src.inputIndex]?.add(src.statIndex)
76
+ }
77
+ }
78
+ return recipe.inputs.map((input, i) => {
79
+ const keys = getItemStatKeys(input.itemId)
80
+ return [...usedIdx[i]]
81
+ .sort((a, b) => a - b)
82
+ .map((idx) => keys[idx] ?? '')
83
+ .filter(Boolean)
84
+ })
85
+ }
86
+
69
87
  export function decodeCraftedItemStats(itemId: number, stats: bigint): Record<string, number> {
70
88
  const keys = getItemStatKeys(itemId)
71
89
  const result: Record<string, number> = {}
@@ -319,7 +319,11 @@ export {
319
319
  availableForItem,
320
320
  cargoReadyAt,
321
321
  taskCargoEffect,
322
+ calcCounterpartDelivery,
323
+ cargoKey,
324
+ cargoInputKey,
322
325
  } from './scheduling/availability'
326
+ export type {CargoInput, IncomingSource} from './scheduling/availability'
323
327
 
324
328
  export {maxCraftable} from './capabilities/craftable'
325
329
 
@@ -412,6 +416,7 @@ export {
412
416
  decodeStat,
413
417
  decodeStats,
414
418
  decodeCraftedItemStats,
419
+ usedInputStatKeys,
415
420
  blendStacks,
416
421
  computeComponentStats,
417
422
  blendComponentStacks,
@@ -95,20 +95,24 @@ export class ConstructionManager extends BaseManager {
95
95
  now: Date
96
96
  ): Map<string, InboundTransfer[]> {
97
97
  const buckets = new Map<string, Map<string, InboundTransfer>>()
98
+ const entitiesById = new Map(entities.map((entity) => [entity.id.toString(), entity]))
98
99
  const nowMs = now.getTime()
99
100
  for (const entity of entities) {
100
- const entityIdStr = entity.id.toString()
101
- const sourceName = entity.entity_name || entityIdStr
102
101
  for (const lane of getLanes(entity)) {
103
102
  const startedMs = lane.schedule.started.toDate().getTime()
104
103
  let cumulativeSec = 0
105
104
  for (const task of lane.schedule.tasks) {
106
105
  cumulativeSec += task.duration.toNumber()
107
- if (!isPushTask(task)) continue
108
- if (task.couplings.length === 0) continue
106
+ if (!isDeliveryTask(task)) continue
107
+ const target = deliveryTarget(task)
108
+ if (!target) continue
109
+ const source = deliverySource(task, entity, entitiesById)
110
+ if (!source) continue
109
111
  const projectedEndMs = startedMs + cumulativeSec * 1000
110
112
  if (projectedEndMs < nowMs) continue
111
- const targetIdStr = task.couplings[0].counterpart.entity_id.toString()
113
+ const targetIdStr = target.entity_id.toString()
114
+ const sourceIdStr = source.id.toString()
115
+ const sourceName = source.entity_name || sourceIdStr
112
116
  const etaSeconds = Math.max(0, Math.round((projectedEndMs - nowMs) / 1000))
113
117
  let perTarget = buckets.get(targetIdStr)
114
118
  if (!perTarget) {
@@ -119,15 +123,15 @@ export class ConstructionManager extends BaseManager {
119
123
  const itemId = c.item_id.toNumber()
120
124
  const quantity = c.quantity.toNumber()
121
125
  if (quantity === 0) continue
122
- const key = `${entityIdStr}#${itemId}`
126
+ const key = `${sourceIdStr}#${itemId}`
123
127
  const existing = perTarget.get(key)
124
128
  if (existing) {
125
129
  existing.quantity += quantity
126
130
  existing.etaSeconds = Math.min(existing.etaSeconds, etaSeconds)
127
131
  } else {
128
132
  perTarget.set(key, {
129
- sourceEntityId: entity.id,
130
- sourceEntityType: entity.type,
133
+ sourceEntityId: source.id,
134
+ sourceEntityType: source.type,
131
135
  sourceName,
132
136
  itemId,
133
137
  quantity,
@@ -363,7 +367,34 @@ function partitionSources(
363
367
  return {eligible, unreachable}
364
368
  }
365
369
 
366
- function isPushTask(task: ServerContract.Types.task): boolean {
370
+ function isDeliveryTask(task: ServerContract.Types.task): boolean {
371
+ const type = task.type.toNumber()
372
+ return type === TaskType.UNLOAD || type === TaskType.SHUTTLE
373
+ }
374
+
375
+ function deliveryTarget(
376
+ task: ServerContract.Types.task
377
+ ): ServerContract.Types.entity_ref | undefined {
378
+ if (task.type.toNumber() === TaskType.SHUTTLE) {
379
+ return task.couplings.find((coupling) => coupling.kind.toNumber() === HoldKind.PUSH)
380
+ ?.counterpart
381
+ }
382
+ return task.couplings[0]?.counterpart
383
+ }
384
+
385
+ function deliverySource(
386
+ task: ServerContract.Types.task,
387
+ transporter: ServerContract.Types.entity_info,
388
+ entitiesById: ReadonlyMap<string, ServerContract.Types.entity_info>
389
+ ): ServerContract.Types.entity_info | undefined {
390
+ if (task.type.toNumber() !== TaskType.SHUTTLE) return transporter
391
+ const source = task.couplings.find(
392
+ (coupling) => coupling.kind.toNumber() === HoldKind.PULL
393
+ )?.counterpart
394
+ return source ? entitiesById.get(source.entity_id.toString()) : undefined
395
+ }
396
+
397
+ function isReservingPushTask(task: ServerContract.Types.task): boolean {
367
398
  return task.type.toNumber() === TaskType.UNLOAD
368
399
  }
369
400
 
@@ -378,7 +409,7 @@ function isBuildOfPlot(task: ServerContract.Types.task, plotId: UInt64): boolean
378
409
  function reservationsOf(source: ServerContract.Types.entity_info): Reservation[] {
379
410
  const out = new Map<string, Reservation>()
380
411
  for (const task of getTasks(source)) {
381
- if (!isPushTask(task)) continue
412
+ if (!isReservingPushTask(task)) continue
382
413
  if (task.couplings.length === 0) continue
383
414
  const targetType = task.couplings[0].counterpart.entity_type
384
415
  const targetId = task.couplings[0].counterpart.entity_id
@@ -0,0 +1,157 @@
1
+ import {describe, expect, test} from 'bun:test'
2
+ import {HoldKind, ServerContract, TaskType} from '../index-module'
3
+ import {
4
+ calcCounterpartDelivery,
5
+ cargoReadyAt,
6
+ type CargoInput,
7
+ type IncomingSource,
8
+ projectedCargoAvailableAt,
9
+ } from './availability'
10
+
11
+ const T0 = '2026-06-19T00:00:00'
12
+ const EPOCH = new Date(0)
13
+
14
+ function cargoItem(itemId: number, stats: number, quantity: number) {
15
+ return ServerContract.Types.cargo_item.from({item_id: itemId, stats, modules: [], quantity})
16
+ }
17
+
18
+ function task(over: {type?: number; duration?: number; cargo?: ReturnType<typeof cargoItem>[]}) {
19
+ return ServerContract.Types.task.from({
20
+ type: over.type ?? TaskType.LOAD,
21
+ duration: over.duration ?? 60,
22
+ cancelable: 0,
23
+ cargo: over.cargo ?? [],
24
+ couplings: [],
25
+ })
26
+ }
27
+
28
+ function coupling(kind: number) {
29
+ return ServerContract.Types.coupling.from({
30
+ counterpart: {entity_type: 'ship', entity_id: 2},
31
+ hold: 1,
32
+ kind,
33
+ })
34
+ }
35
+
36
+ function entity(
37
+ cargo: ReturnType<typeof cargoItem>[],
38
+ tasks: ReturnType<typeof task>[] = [],
39
+ startedISO = T0
40
+ ) {
41
+ return ServerContract.Types.entity_info.from({
42
+ type: 'ship',
43
+ id: 1,
44
+ owner: 'player.gm',
45
+ entity_name: 'Ship 1',
46
+ coordinates: {x: 0, y: 0, z: 0},
47
+ item_id: 1,
48
+ cargomass: 0,
49
+ cargo: cargo.map((c) => ({
50
+ item_id: c.item_id,
51
+ stats: c.stats,
52
+ modules: c.modules,
53
+ quantity: c.quantity,
54
+ id: 0,
55
+ })),
56
+ modules: [],
57
+ lanes: [{lane_key: 0, schedule: {started: startedISO, tasks}}],
58
+ gatherer_lanes: [],
59
+ crafter_lanes: [],
60
+ builder_lanes: [],
61
+ loader_lanes: [],
62
+ holds: [],
63
+ })
64
+ }
65
+
66
+ describe('cargoReadyAt', () => {
67
+ test('on-hand covers demand → epoch', () => {
68
+ const e = entity([cargoItem(7, 0, 10)])
69
+ const demand: CargoInput[] = [{itemId: 7, stats: 0n, quantity: 5}]
70
+ expect(cargoReadyAt(e, demand)).toEqual(EPOCH)
71
+ })
72
+
73
+ test('own-lane LOAD covers demand → its completion', () => {
74
+ const loadCompletesAt = new Date('2026-06-19T00:01:00.000Z')
75
+ const e = entity(
76
+ [],
77
+ [task({type: TaskType.LOAD, duration: 60, cargo: [cargoItem(7, 0, 10)]})]
78
+ )
79
+ const demand: CargoInput[] = [{itemId: 7, stats: 0n, quantity: 5}]
80
+ expect(cargoReadyAt(e, demand)).toEqual(loadCompletesAt)
81
+ })
82
+
83
+ test('incoming source covers demand → its until', () => {
84
+ const until = new Date('2026-06-19T00:00:30.000Z')
85
+ const e = entity([])
86
+ const incoming: IncomingSource[] = [{holdId: '1', until, items: [cargoItem(7, 0, 10)]}]
87
+ const demand: CargoInput[] = [{itemId: 7, stats: 0n, quantity: 5}]
88
+ expect(cargoReadyAt(e, demand, incoming)).toEqual(until)
89
+ })
90
+
91
+ test('on-hand covers demand; unrelated same-item different-stats inflow does not delay readiness', () => {
92
+ const laterCompletesAt = new Date('2026-06-19T00:01:00.000Z')
93
+ const e = entity(
94
+ [cargoItem(7, 0, 10)],
95
+ [task({type: TaskType.LOAD, duration: 60, cargo: [cargoItem(7, 99, 10)]})]
96
+ )
97
+ const demand: CargoInput[] = [{itemId: 7, stats: 0n, quantity: 5}]
98
+ const result = cargoReadyAt(e, demand)
99
+ expect(result).toEqual(EPOCH)
100
+ expect(result).not.toEqual(laterCompletesAt)
101
+ })
102
+ })
103
+
104
+ describe('calcCounterpartDelivery', () => {
105
+ test('coupled CRAFT task returns output cargo only', () => {
106
+ const output = cargoItem(9, 0, 1)
107
+ const t = task({
108
+ type: TaskType.CRAFT,
109
+ cargo: [cargoItem(7, 0, 2), cargoItem(8, 0, 1), output],
110
+ })
111
+ const result = calcCounterpartDelivery(t, coupling(HoldKind.PUSH))
112
+ expect(result.length).toBe(1)
113
+ expect(result[0].item_id.toNumber()).toBe(9)
114
+ })
115
+
116
+ test('CRAFT task with empty cargo returns []', () => {
117
+ const t = task({type: TaskType.CRAFT, cargo: []})
118
+ expect(calcCounterpartDelivery(t, coupling(HoldKind.PUSH))).toEqual([])
119
+ })
120
+
121
+ test('coupled UNLOAD task returns whole cargo', () => {
122
+ const t = task({type: TaskType.UNLOAD, cargo: [cargoItem(7, 0, 2), cargoItem(8, 0, 1)]})
123
+ const result = calcCounterpartDelivery(t, coupling(HoldKind.GATHER))
124
+ expect(result.length).toBe(2)
125
+ })
126
+
127
+ test('non-incoming coupling kind returns []', () => {
128
+ const t = task({type: TaskType.UNLOAD, cargo: [cargoItem(7, 0, 2)]})
129
+ expect(calcCounterpartDelivery(t, coupling(HoldKind.PULL))).toEqual([])
130
+ })
131
+ })
132
+
133
+ describe('projectedCargoAvailableAt — incoming', () => {
134
+ const until = new Date('2026-06-19T00:00:30.000Z')
135
+
136
+ function incomingFor(qty: number): IncomingSource[] {
137
+ return [{holdId: '1', until, items: [cargoItem(7, 0, qty)]}]
138
+ }
139
+
140
+ test('credits an incoming source strictly before its until', () => {
141
+ const e = entity([])
142
+ const avail = projectedCargoAvailableAt(e, new Date(until.getTime() + 1), incomingFor(10))
143
+ expect(avail.get('7:0')).toBe(10n)
144
+ })
145
+
146
+ test('does not credit an incoming source at exactly its until', () => {
147
+ const e = entity([])
148
+ const avail = projectedCargoAvailableAt(e, until, incomingFor(10))
149
+ expect(avail.get('7:0') ?? 0n).toBe(0n)
150
+ })
151
+
152
+ test('does not credit an incoming source before its until', () => {
153
+ const e = entity([])
154
+ const avail = projectedCargoAvailableAt(e, new Date(until.getTime() - 1), incomingFor(10))
155
+ expect(avail.get('7:0') ?? 0n).toBe(0n)
156
+ })
157
+ })
@@ -1,9 +1,11 @@
1
1
  import type {ServerContract} from '../contracts'
2
- import {TaskType} from '../types'
2
+ import {HoldKind, TaskType} from '../types'
3
3
  import * as schedule from './schedule'
4
4
 
5
5
  type Task = ServerContract.Types.task
6
+ type Coupling = ServerContract.Types.coupling
6
7
  type CargoItem = ServerContract.Types.cargo_item
8
+ type ModuleEntry = ServerContract.Types.module_entry
7
9
 
8
10
  export interface CargoEffect {
9
11
  added: CargoItem[]
@@ -14,6 +16,35 @@ export interface AvailabilityInput extends schedule.ScheduleData {
14
16
  cargo: CargoItem[]
15
17
  }
16
18
 
19
+ export interface CargoInput {
20
+ itemId: number
21
+ stats: bigint
22
+ modules?: ModuleEntry[]
23
+ quantity: number
24
+ }
25
+
26
+ export interface IncomingSource {
27
+ holdId: string
28
+ until: Date
29
+ items: CargoItem[]
30
+ }
31
+
32
+ const INCOMING_COUPLING_KINDS = new Set<number>([HoldKind.PUSH, HoldKind.GATHER, HoldKind.FLIGHT])
33
+
34
+ // Mirrors is_incoming_hold_kind: PUSH/GATHER/FLIGHT couplings deliver to the counterpart.
35
+ export function isIncomingCouplingKind(kind: number): boolean {
36
+ return INCOMING_COUPLING_KINDS.has(kind)
37
+ }
38
+
39
+ // Mirrors calc_counterpart_delivery: incoming-kind couplings only; a coupled CRAFT yields its output slot.
40
+ export function calcCounterpartDelivery(task: Task, coupling: Coupling): CargoItem[] {
41
+ if (!isIncomingCouplingKind(coupling.kind.toNumber())) return []
42
+ if (task.type.toNumber() === TaskType.CRAFT) {
43
+ return task.cargo.length === 0 ? [] : [task.cargo[task.cargo.length - 1]]
44
+ }
45
+ return task.cargo
46
+ }
47
+
17
48
  export function taskCargoEffect(task: Task): CargoEffect {
18
49
  switch (task.type.toNumber()) {
19
50
  case TaskType.LOAD:
@@ -21,6 +52,7 @@ export function taskCargoEffect(task: Task): CargoEffect {
21
52
  case TaskType.UNDEPLOY:
22
53
  return {added: task.cargo, removed: []}
23
54
  case TaskType.UNLOAD:
55
+ case TaskType.UPGRADE:
24
56
  return {added: [], removed: task.cargo}
25
57
  case TaskType.GATHER:
26
58
  return task.couplings.length > 0
@@ -37,22 +69,34 @@ export function taskCargoEffect(task: Task): CargoEffect {
37
69
  }
38
70
  }
39
71
 
40
- export function cargoKey(item: CargoItem): string {
41
- const base = `${item.item_id.toNumber()}:${item.stats.toString()}`
42
- const modules = item.modules ?? []
43
- const entityId = item.entity_id?.toString()
44
- const normalizedEntityId = entityId && entityId !== '0' ? entityId : ''
72
+ function refKey(itemId: number, stats: bigint, modules: ModuleEntry[], entityId: string): string {
73
+ const base = `${itemId}:${stats.toString()}`
74
+ const normalizedEntityId = entityId !== '0' ? entityId : ''
45
75
  if (modules.length === 0 && normalizedEntityId === '') return base
46
76
  return `${base}:modules=${JSON.stringify(modules)}:entity=${normalizedEntityId}`
47
77
  }
48
78
 
79
+ export function cargoKey(item: CargoItem): string {
80
+ return refKey(
81
+ item.item_id.toNumber(),
82
+ BigInt(item.stats.toString()),
83
+ item.modules ?? [],
84
+ item.entity_id?.toString() ?? ''
85
+ )
86
+ }
87
+
88
+ export function cargoInputKey(input: CargoInput): string {
89
+ return refKey(input.itemId, input.stats, input.modules ?? [], '')
90
+ }
91
+
49
92
  function cargoQuantity(item: CargoItem): bigint {
50
93
  return BigInt(item.quantity.toString())
51
94
  }
52
95
 
53
96
  export function projectedCargoAvailableAt(
54
97
  entity: AvailabilityInput,
55
- at: Date
98
+ at: Date,
99
+ incoming: readonly IncomingSource[] = []
56
100
  ): Map<string, bigint> {
57
101
  const avail = new Map<string, bigint>()
58
102
 
@@ -73,6 +117,14 @@ export function projectedCargoAvailableAt(
73
117
  }
74
118
  }
75
119
 
120
+ for (const src of incoming) {
121
+ if (src.until.getTime() >= at.getTime()) continue
122
+ for (const item of src.items) {
123
+ const key = cargoKey(item)
124
+ avail.set(key, (avail.get(key) ?? 0n) + cargoQuantity(item))
125
+ }
126
+ }
127
+
76
128
  for (const ordered of tasks) {
77
129
  for (const item of taskCargoEffect(ordered.task).removed) {
78
130
  const key = cargoKey(item)
@@ -85,18 +137,47 @@ export function projectedCargoAvailableAt(
85
137
  return avail
86
138
  }
87
139
 
88
- // Latest completion among scheduled tasks producing any of the given inputs (a craft starts no earlier).
89
- export function cargoReadyAt(entity: AvailabilityInput, inputItemIds: readonly number[]): Date {
90
- let readyMs = 0
140
+ // Earliest-sufficient candidate (epoch, own-lane producer completions, incoming untils); else the latest candidate.
141
+ export function cargoReadyAt(
142
+ entity: AvailabilityInput,
143
+ inputs: readonly CargoInput[],
144
+ incoming: readonly IncomingSource[] = []
145
+ ): Date {
146
+ const demand = new Map<string, bigint>()
147
+ for (const input of inputs) {
148
+ const key = cargoInputKey(input)
149
+ demand.set(key, (demand.get(key) ?? 0n) + BigInt(input.quantity))
150
+ }
151
+
152
+ const candidates: number[] = [0]
91
153
  for (const ordered of schedule.orderedTasks(entity)) {
92
154
  for (const item of taskCargoEffect(ordered.task).added) {
93
- if (inputItemIds.includes(item.item_id.toNumber())) {
94
- readyMs = Math.max(readyMs, ordered.completesAt.getTime())
155
+ if (demand.has(cargoKey(item))) {
156
+ candidates.push(ordered.completesAt.getTime())
157
+ break
158
+ }
159
+ }
160
+ }
161
+ for (const src of incoming) {
162
+ if (src.items.some((item) => demand.has(cargoKey(item)))) {
163
+ candidates.push(src.until.getTime())
164
+ }
165
+ }
166
+ candidates.sort((a, b) => a - b)
167
+
168
+ for (const candidateMs of candidates) {
169
+ // +1ms mirrors the contract's candidate+1µs probe: inclusive at the candidate instant.
170
+ const available = projectedCargoAvailableAt(entity, new Date(candidateMs + 1), incoming)
171
+ let sufficient = true
172
+ for (const [key, quantity] of demand) {
173
+ if ((available.get(key) ?? 0n) < quantity) {
174
+ sufficient = false
95
175
  break
96
176
  }
97
177
  }
178
+ if (sufficient) return new Date(candidateMs)
98
179
  }
99
- return new Date(readyMs)
180
+ return new Date(candidates[candidates.length - 1])
100
181
  }
101
182
 
102
183
  export function availableForItem(avail: Map<string, bigint>, itemId: number): bigint {