@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.
@@ -1,6 +1,7 @@
1
1
  import {describe, expect, test} from 'bun:test'
2
- import {ServerContract, TaskType, TaskCancelable} from '../index-module'
2
+ import {ServerContract, TaskType, TaskCancelable, HoldKind} from '../index-module'
3
3
  import {cancelEligibility, CancelBlockReason} from './cancel'
4
+ import type {IncomingSource} from './availability'
4
5
 
5
6
  const T0 = '2026-06-19T00:00:00'
6
7
 
@@ -380,3 +381,162 @@ describe('cancelEligibility — feasibility', () => {
380
381
  )
381
382
  })
382
383
  })
384
+
385
+ describe('cancelEligibility — cross-entity strand (counterpart queued consumer)', () => {
386
+ const upcoming = new Date('2026-06-18T23:59:50.000Z')
387
+
388
+ function entityWithId(
389
+ id: number,
390
+ tasks: ReturnType<typeof ServerContract.Types.task.from>[],
391
+ cargo: {
392
+ id: number
393
+ item_id: number
394
+ stats: number
395
+ modules: never[]
396
+ quantity: number
397
+ }[] = []
398
+ ) {
399
+ return ServerContract.Types.entity_info.from({
400
+ type: 'ship',
401
+ id,
402
+ owner: 'player.gm',
403
+ entity_name: `Ship ${id}`,
404
+ coordinates: {x: 0, y: 0, z: 0},
405
+ item_id: 1,
406
+ cargomass: 0,
407
+ cargo,
408
+ modules: [],
409
+ lanes: [{lane_key: 0, schedule: {started: T0, tasks}}],
410
+ gatherer_lanes: [],
411
+ crafter_lanes: [],
412
+ builder_lanes: [],
413
+ loader_lanes: [],
414
+ holds: [],
415
+ })
416
+ }
417
+
418
+ function pushTask() {
419
+ return ServerContract.Types.task.from({
420
+ type: TaskType.UNLOAD,
421
+ duration: 50,
422
+ cancelable: TaskCancelable.ALWAYS,
423
+ cargo: [{item_id: 7, stats: 0, modules: [], quantity: 2}],
424
+ couplings: [
425
+ {counterpart: {entity_type: 'ship', entity_id: 2}, hold: 1, kind: HoldKind.PUSH},
426
+ ],
427
+ })
428
+ }
429
+
430
+ function craftConsumerTask() {
431
+ return ServerContract.Types.task.from({
432
+ type: TaskType.CRAFT,
433
+ duration: 100,
434
+ cancelable: TaskCancelable.ALWAYS,
435
+ cargo: [
436
+ {item_id: 7, stats: 0, modules: [], quantity: 2},
437
+ {item_id: 9, stats: 0, modules: [], quantity: 1},
438
+ ],
439
+ couplings: [],
440
+ })
441
+ }
442
+
443
+ function incoming(): IncomingSource[] {
444
+ return [
445
+ {
446
+ holdId: '1',
447
+ until: new Date('2026-06-19T00:00:50.000Z'),
448
+ items: [
449
+ ServerContract.Types.cargo_item.from({
450
+ item_id: 7,
451
+ stats: 0,
452
+ modules: [],
453
+ quantity: 2,
454
+ }),
455
+ ],
456
+ },
457
+ ]
458
+ }
459
+
460
+ function upgradeConsumerTask() {
461
+ return ServerContract.Types.task.from({
462
+ type: TaskType.UPGRADE,
463
+ duration: 100,
464
+ cancelable: TaskCancelable.ALWAYS,
465
+ cargo: [{item_id: 7, stats: 0, modules: [], quantity: 2}],
466
+ couplings: [],
467
+ })
468
+ }
469
+
470
+ test('blocked: counterpart consumer loses coverage without this delivery', () => {
471
+ const producer = entityWithId(1, [pushTask()])
472
+ const counterpart = entityWithId(2, [craftConsumerTask()])
473
+ const plan = cancelEligibility(producer, 0, 0, {
474
+ now: upcoming,
475
+ counterparts: new Map([['2', counterpart]]),
476
+ counterpartIncoming: new Map([['2', incoming()]]),
477
+ })
478
+ expect(plan.ok).toBe(false)
479
+ expect(plan.blockedReason).toBe(CancelBlockReason.WOULD_STRAND_COUNTERPART)
480
+ expect(plan.blockedByCounterpart?.entity_id.toNumber()).toBe(2)
481
+ })
482
+
483
+ test('blocked: counterpart upgrade consumer loses coverage without this delivery', () => {
484
+ const producer = entityWithId(1, [pushTask()])
485
+ const counterpart = entityWithId(2, [upgradeConsumerTask()])
486
+ const plan = cancelEligibility(producer, 0, 0, {
487
+ now: upcoming,
488
+ counterparts: new Map([['2', counterpart]]),
489
+ counterpartIncoming: new Map([['2', incoming()]]),
490
+ })
491
+ expect(plan.ok).toBe(false)
492
+ expect(plan.blockedReason).toBe(CancelBlockReason.WOULD_STRAND_COUNTERPART)
493
+ expect(plan.blockedByCounterpart?.entity_id.toNumber()).toBe(2)
494
+ })
495
+
496
+ test('allowed: counterpart upgrade consumer already covered on-hand', () => {
497
+ const producer = entityWithId(1, [pushTask()])
498
+ const counterpart = entityWithId(
499
+ 2,
500
+ [upgradeConsumerTask()],
501
+ [{id: 1, item_id: 7, stats: 0, modules: [], quantity: 2}]
502
+ )
503
+ const plan = cancelEligibility(producer, 0, 0, {
504
+ now: upcoming,
505
+ counterparts: new Map([['2', counterpart]]),
506
+ counterpartIncoming: new Map([['2', incoming()]]),
507
+ })
508
+ expect(plan.ok).toBe(true)
509
+ })
510
+
511
+ test('allowed: counterpart consumer already covered on-hand (surplus coverage)', () => {
512
+ const producer = entityWithId(1, [pushTask()])
513
+ const counterpart = entityWithId(
514
+ 2,
515
+ [craftConsumerTask()],
516
+ [{id: 1, item_id: 7, stats: 0, modules: [], quantity: 2}]
517
+ )
518
+ const plan = cancelEligibility(producer, 0, 0, {
519
+ now: upcoming,
520
+ counterparts: new Map([['2', counterpart]]),
521
+ counterpartIncoming: new Map([['2', incoming()]]),
522
+ })
523
+ expect(plan.ok).toBe(true)
524
+ })
525
+
526
+ test('allowed: counterpart data not loaded, check skipped gracefully', () => {
527
+ const producer = entityWithId(1, [pushTask()])
528
+ const plan = cancelEligibility(producer, 0, 0, {now: upcoming})
529
+ expect(plan.ok).toBe(true)
530
+ })
531
+
532
+ test('allowed: counterpart has no queued consumer', () => {
533
+ const producer = entityWithId(1, [pushTask()])
534
+ const idleCounterpart = entityWithId(2, [])
535
+ const plan = cancelEligibility(producer, 0, 0, {
536
+ now: upcoming,
537
+ counterparts: new Map([['2', idleCounterpart]]),
538
+ counterpartIncoming: new Map([['2', incoming()]]),
539
+ })
540
+ expect(plan.ok).toBe(true)
541
+ })
542
+ })
@@ -1,7 +1,12 @@
1
1
  import type {ServerContract} from '../contracts'
2
2
  import {HoldKind, TaskCancelable, TaskType} from '../types'
3
3
  import {calcCargoItemMass} from '../capabilities/storage'
4
- import {taskCargoEffect, cargoKey} from './availability'
4
+ import {
5
+ taskCargoEffect,
6
+ cargoKey,
7
+ isIncomingCouplingKind,
8
+ type IncomingSource,
9
+ } from './availability'
5
10
  import * as schedule from './schedule'
6
11
  import {validateSchedule, type Projectable} from './projection'
7
12
 
@@ -11,6 +16,7 @@ export enum CancelBlockReason {
11
16
  DONE = 'DONE',
12
17
  CONTAINS_LINKED_TASK = 'CONTAINS_LINKED_TASK',
13
18
  WOULD_STRAND = 'WOULD_STRAND',
19
+ WOULD_STRAND_COUNTERPART = 'WOULD_STRAND_COUNTERPART',
14
20
  WOULD_OVERFILL = 'WOULD_OVERFILL',
15
21
  NOT_OWNER = 'NOT_OWNER',
16
22
  }
@@ -38,12 +44,14 @@ export interface CancelEffects {
38
44
  export interface CancelPlan {
39
45
  ok: boolean
40
46
  blockedReason?: CancelBlockReason
47
+ blockedByCounterpart?: EntityRef
41
48
  range: {count: number; taskIndices: number[]}
42
49
  effects: CancelEffects
43
50
  }
44
51
  export interface CancelEligibilityInput {
45
52
  now: Date
46
53
  counterparts?: Map<string, EntityInfo>
54
+ counterpartIncoming?: Map<string, readonly IncomingSource[]>
47
55
  }
48
56
 
49
57
  const EMPTY_EFFECTS: CancelEffects = {refunds: [], releasedHolds: [], abandonsRunning: false}
@@ -57,15 +65,18 @@ function postCancelEntity(entity: EntityInfo, laneKey: number, fromTaskIndex: nu
57
65
  return clone
58
66
  }
59
67
 
60
- function feasibleAfterCancel(post: EntityInfo): boolean {
61
- const ordered = schedule.orderedTasks(post as unknown as Projectable)
68
+ // C1: every pending consumer keeps its inputs covered by on-hand + own-lane producers strictly-before + incoming strictly-before.
69
+ function queueFeasible(entity: EntityInfo, incoming: readonly IncomingSource[] = []): boolean {
70
+ const ordered = schedule.orderedTasks(entity as unknown as Projectable)
62
71
  const base = new Map<string, number>()
63
- for (const c of post.cargo ?? []) {
72
+ for (const c of entity.cargo ?? []) {
64
73
  const k = cargoKey(c)
65
74
  base.set(k, (base.get(k) ?? 0) + c.quantity.toNumber())
66
75
  }
67
76
  const isConsumer = (t: Task) =>
68
- t.type.toNumber() === TaskType.CRAFT || t.type.toNumber() === TaskType.UNLOAD
77
+ t.type.toNumber() === TaskType.CRAFT ||
78
+ t.type.toNumber() === TaskType.UNLOAD ||
79
+ t.type.toNumber() === TaskType.UPGRADE
69
80
  for (const self of ordered) {
70
81
  if (!isConsumer(self.task)) continue
71
82
  const map = new Map(base)
@@ -75,6 +86,12 @@ function feasibleAfterCancel(post: EntityInfo): boolean {
75
86
  map.set(cargoKey(out), (map.get(cargoKey(out)) ?? 0) + out.quantity.toNumber())
76
87
  }
77
88
  }
89
+ for (const src of incoming) {
90
+ if (src.until.getTime() >= self.completesAt.getTime()) continue
91
+ for (const item of src.items) {
92
+ map.set(cargoKey(item), (map.get(cargoKey(item)) ?? 0) + item.quantity.toNumber())
93
+ }
94
+ }
78
95
  for (const other of ordered) {
79
96
  if (other === self) continue
80
97
  for (const inp of taskCargoEffect(other.task).removed) {
@@ -86,6 +103,11 @@ function feasibleAfterCancel(post: EntityInfo): boolean {
86
103
  if ((map.get(cargoKey(inp)) ?? 0) < inp.quantity.toNumber()) return false
87
104
  }
88
105
  }
106
+ return true
107
+ }
108
+
109
+ function feasibleAfterCancel(post: EntityInfo): boolean {
110
+ if (!queueFeasible(post)) return false
89
111
  try {
90
112
  validateSchedule(post as unknown as Projectable)
91
113
  } catch {
@@ -162,6 +184,37 @@ export function cancelEligibility(
162
184
  const post = postCancelEntity(entity, laneKey, fromTaskIndex)
163
185
  if (!feasibleAfterCancel(post)) return block(CancelBlockReason.WOULD_STRAND)
164
186
 
187
+ // cross-entity C1: a counterpart losing this delivery must still feed its own queued consumers.
188
+ const releasedByCounterpart = new Map<string, {counterpart: EntityRef; holdIds: Set<string>}>()
189
+ for (const i of taskIndices) {
190
+ for (const c of tasks[i].couplings ?? []) {
191
+ if (!isIncomingCouplingKind(c.kind.toNumber())) continue
192
+ const id = c.counterpart.entity_id.toString()
193
+ const entry = releasedByCounterpart.get(id) ?? {
194
+ counterpart: c.counterpart,
195
+ holdIds: new Set<string>(),
196
+ }
197
+ entry.holdIds.add(c.hold.toString())
198
+ releasedByCounterpart.set(id, entry)
199
+ }
200
+ }
201
+ for (const [counterpartId, released] of releasedByCounterpart) {
202
+ const counterpart = input.counterparts?.get(counterpartId)
203
+ if (!counterpart) continue
204
+ const incoming = (input.counterpartIncoming?.get(counterpartId) ?? []).filter(
205
+ (src) => !released.holdIds.has(src.holdId)
206
+ )
207
+ if (!queueFeasible(counterpart, incoming)) {
208
+ return {
209
+ ok: false,
210
+ blockedReason: CancelBlockReason.WOULD_STRAND_COUNTERPART,
211
+ blockedByCounterpart: released.counterpart,
212
+ range,
213
+ effects: {...EMPTY_EFFECTS},
214
+ }
215
+ }
216
+ }
217
+
165
218
  const effects: CancelEffects = {refunds: [], releasedHolds: [], abandonsRunning: false}
166
219
  let energyForfeited = 0
167
220
  for (const i of taskIndices) {