@shipload/sdk 1.0.0-next.6 → 1.0.0-next.61

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 (156) hide show
  1. package/lib/scan.d.ts +52 -0
  2. package/lib/scan.js +162 -0
  3. package/lib/scan.js.map +1 -0
  4. package/lib/scan.m.js +152 -0
  5. package/lib/scan.m.js.map +1 -0
  6. package/lib/shipload.d.ts +3601 -1364
  7. package/lib/shipload.js +15671 -5261
  8. package/lib/shipload.js.map +1 -1
  9. package/lib/shipload.m.js +15392 -5209
  10. package/lib/shipload.m.js.map +1 -1
  11. package/lib/testing.d.ts +1173 -0
  12. package/lib/testing.js +4971 -0
  13. package/lib/testing.js.map +1 -0
  14. package/lib/testing.m.js +4965 -0
  15. package/lib/testing.m.js.map +1 -0
  16. package/package.json +20 -2
  17. package/src/capabilities/craftable.test.ts +82 -0
  18. package/src/capabilities/craftable.ts +82 -0
  19. package/src/capabilities/crafting.test.ts +35 -0
  20. package/src/capabilities/crafting.ts +34 -7
  21. package/src/capabilities/gathering.test.ts +29 -0
  22. package/src/capabilities/gathering.ts +37 -19
  23. package/src/capabilities/hauling.ts +0 -5
  24. package/src/capabilities/index.ts +0 -1
  25. package/src/capabilities/modules.ts +27 -30
  26. package/src/capabilities/movement.ts +1 -1
  27. package/src/capabilities/storage.ts +16 -1
  28. package/src/contracts/platform.ts +231 -3
  29. package/src/contracts/server.ts +1518 -501
  30. package/src/coordinates/address.ts +88 -0
  31. package/src/coordinates/constants.test.ts +15 -0
  32. package/src/coordinates/constants.ts +23 -0
  33. package/src/coordinates/index.ts +15 -0
  34. package/src/coordinates/memo.test.ts +47 -0
  35. package/src/coordinates/memo.ts +20 -0
  36. package/src/coordinates/permutation.ts +77 -0
  37. package/src/coordinates/regions.ts +48 -0
  38. package/src/coordinates/sectors.ts +115 -0
  39. package/src/data/capabilities.ts +49 -18
  40. package/src/data/capability-formulas.ts +26 -14
  41. package/src/data/catalog.ts +7 -8
  42. package/src/data/colors.ts +14 -48
  43. package/src/data/entities.json +380 -15
  44. package/src/data/item-ids.ts +51 -13
  45. package/src/data/items.json +318 -77
  46. package/src/data/kind-registry.json +195 -0
  47. package/src/data/kind-registry.ts +182 -0
  48. package/src/data/metadata.ts +291 -51
  49. package/src/data/recipes-runtime.ts +6 -23
  50. package/src/data/recipes.json +2260 -173
  51. package/src/data/tiers.ts +9 -6
  52. package/src/derivation/build-methods.test.ts +13 -0
  53. package/src/derivation/build-methods.ts +48 -0
  54. package/src/derivation/capabilities.test.ts +348 -0
  55. package/src/derivation/capabilities.ts +693 -0
  56. package/src/derivation/capability-mappings.ts +60 -16
  57. package/src/derivation/crafting.test.ts +17 -0
  58. package/src/derivation/crafting.ts +57 -34
  59. package/src/derivation/index.ts +27 -2
  60. package/src/derivation/recipe-usage.test.ts +90 -0
  61. package/src/derivation/recipe-usage.ts +141 -0
  62. package/src/derivation/reserve-regen.ts +34 -0
  63. package/src/derivation/resources.ts +9 -1
  64. package/src/derivation/rollups.test.ts +55 -0
  65. package/src/derivation/rollups.ts +72 -0
  66. package/src/derivation/stars.test.ts +67 -0
  67. package/src/derivation/stars.ts +25 -0
  68. package/src/derivation/stat-scaling.ts +12 -0
  69. package/src/derivation/stats.ts +6 -6
  70. package/src/derivation/stratum.ts +26 -22
  71. package/src/derivation/tiers.ts +40 -7
  72. package/src/derivation/upgrades.ts +14 -0
  73. package/src/derivation/wormhole.ts +154 -0
  74. package/src/entities/entity.ts +102 -0
  75. package/src/entities/gamestate.ts +3 -28
  76. package/src/entities/makers.ts +141 -135
  77. package/src/entities/slot-multiplier.ts +43 -0
  78. package/src/errors.ts +12 -16
  79. package/src/format.ts +26 -4
  80. package/src/index-module.ts +333 -35
  81. package/src/managers/actions.ts +668 -103
  82. package/src/managers/base.ts +6 -2
  83. package/src/managers/cluster.test.ts +39 -0
  84. package/src/managers/cluster.ts +53 -0
  85. package/src/managers/construction-types.ts +80 -0
  86. package/src/managers/construction.ts +443 -0
  87. package/src/managers/context.ts +29 -1
  88. package/src/managers/coordinates.ts +14 -0
  89. package/src/managers/entities.ts +27 -66
  90. package/src/managers/epochs.ts +40 -0
  91. package/src/managers/index.ts +20 -1
  92. package/src/managers/locations.ts +25 -29
  93. package/src/managers/nft.test.ts +14 -0
  94. package/src/managers/nft.ts +70 -0
  95. package/src/managers/players.ts +25 -0
  96. package/src/managers/plot.ts +122 -0
  97. package/src/nft/atomicassets.abi.json +1342 -0
  98. package/src/nft/atomicassets.ts +237 -0
  99. package/src/nft/atomicdata.ts +130 -0
  100. package/src/nft/buildImmutableData.ts +388 -0
  101. package/src/nft/description.ts +215 -99
  102. package/src/nft/index.ts +3 -0
  103. package/src/planner/index.ts +242 -0
  104. package/src/planner/planner.test.ts +334 -0
  105. package/src/resolution/describe-module.ts +43 -25
  106. package/src/resolution/display-name.ts +38 -10
  107. package/src/resolution/resolve-item.test.ts +48 -0
  108. package/src/resolution/resolve-item.ts +112 -58
  109. package/src/scan/index.ts +244 -0
  110. package/src/scan/scan-wasm.base64.ts +2 -0
  111. package/src/scheduling/accessor.ts +65 -23
  112. package/src/scheduling/availability.test.ts +204 -0
  113. package/src/scheduling/availability.ts +211 -0
  114. package/src/scheduling/cancel.test.ts +542 -0
  115. package/src/scheduling/cancel.ts +254 -0
  116. package/src/scheduling/cluster-stock.test.ts +31 -0
  117. package/src/scheduling/cluster-stock.ts +15 -0
  118. package/src/scheduling/energy.ts +47 -0
  119. package/src/scheduling/idle-resolve.ts +45 -0
  120. package/src/scheduling/jobs.ts +71 -0
  121. package/src/scheduling/lane-core.ts +128 -0
  122. package/src/scheduling/lanes.test.ts +250 -0
  123. package/src/scheduling/lanes.ts +227 -0
  124. package/src/scheduling/projection.ts +257 -133
  125. package/src/scheduling/schedule.test.ts +119 -0
  126. package/src/scheduling/schedule.ts +257 -117
  127. package/src/scheduling/task-cargo.test.ts +44 -0
  128. package/src/scheduling/task-cargo.ts +46 -0
  129. package/src/scheduling/unwrap.test.ts +86 -0
  130. package/src/scheduling/unwrap.ts +190 -0
  131. package/src/shipload.ts +26 -1
  132. package/src/subscriptions/manager.cluster.test.ts +51 -0
  133. package/src/subscriptions/manager.ts +245 -172
  134. package/src/subscriptions/mappers.ts +5 -8
  135. package/src/subscriptions/types.ts +28 -3
  136. package/src/testing/catalog-hash.ts +19 -0
  137. package/src/testing/index.ts +2 -0
  138. package/src/testing/projection-parity.ts +167 -0
  139. package/src/travel/reach.ts +21 -0
  140. package/src/travel/route-planner.ts +288 -0
  141. package/src/travel/route-simulator.ts +170 -0
  142. package/src/travel/travel.ts +188 -122
  143. package/src/types/capabilities.ts +37 -8
  144. package/src/types/entity.ts +3 -3
  145. package/src/types/index.ts +0 -1
  146. package/src/types.ts +51 -16
  147. package/src/utils/cargo.test.ts +14 -0
  148. package/src/utils/cargo.ts +29 -0
  149. package/src/utils/display-name.ts +70 -0
  150. package/src/utils/system.ts +15 -16
  151. package/src/capabilities/loading.ts +0 -8
  152. package/src/entities/container.ts +0 -123
  153. package/src/entities/ship-deploy.ts +0 -295
  154. package/src/entities/ship.ts +0 -221
  155. package/src/entities/warehouse.ts +0 -127
  156. package/src/types/entity-traits.ts +0 -69
@@ -0,0 +1,542 @@
1
+ import {describe, expect, test} from 'bun:test'
2
+ import {ServerContract, TaskType, TaskCancelable, HoldKind} from '../index-module'
3
+ import {cancelEligibility, CancelBlockReason} from './cancel'
4
+ import type {IncomingSource} from './availability'
5
+
6
+ const T0 = '2026-06-19T00:00:00'
7
+
8
+ function task(over: Partial<{type: number; duration: number; cancelable: number; group: number}>) {
9
+ return ServerContract.Types.task.from({
10
+ type: over.type ?? TaskType.TRAVEL,
11
+ duration: over.duration ?? 100,
12
+ cancelable: over.cancelable ?? TaskCancelable.BEFORE_START,
13
+ cargo: [],
14
+ couplings: [],
15
+ ...(over.group ? {entitygroup: over.group} : {}),
16
+ })
17
+ }
18
+
19
+ function entity(tasks: ReturnType<typeof task>[], startedISO = T0) {
20
+ return ServerContract.Types.entity_info.from({
21
+ type: 'ship',
22
+ id: 1,
23
+ owner: 'player.gm',
24
+ entity_name: 'Ship 1',
25
+ coordinates: {x: 0, y: 0, z: 0},
26
+ item_id: 1,
27
+ cargomass: 0,
28
+ cargo: [],
29
+ modules: [],
30
+ lanes: [{lane_key: 0, schedule: {started: startedISO, tasks}}],
31
+ gatherer_lanes: [],
32
+ crafter_lanes: [],
33
+ builder_lanes: [],
34
+ loader_lanes: [],
35
+ holds: [],
36
+ })
37
+ }
38
+
39
+ describe('cancelEligibility — local gates', () => {
40
+ const now = new Date('2026-06-19T00:00:10.000Z') // 10s in: task 0 running, task 1 upcoming
41
+
42
+ test('cancelling the last upcoming task: ok, count 1', () => {
43
+ const e = entity([task({}), task({})])
44
+ const plan = cancelEligibility(e, 0, 1, {now})
45
+ expect(plan.ok).toBe(true)
46
+ expect(plan.range.count).toBe(1)
47
+ expect(plan.range.taskIndices).toEqual([1])
48
+ })
49
+
50
+ test('NEVER task is blocked', () => {
51
+ const e = entity([
52
+ task({}),
53
+ task({cancelable: TaskCancelable.NEVER, type: TaskType.GATHER}),
54
+ ])
55
+ expect(cancelEligibility(e, 0, 1, {now}).blockedReason).toBe(CancelBlockReason.TASK_NEVER)
56
+ })
57
+
58
+ test('BEFORE_START task that is running is blocked', () => {
59
+ const e = entity([task({cancelable: TaskCancelable.BEFORE_START, duration: 100})])
60
+ // task 0 is running at now=10s
61
+ expect(cancelEligibility(e, 0, 0, {now}).blockedReason).toBe(
62
+ CancelBlockReason.BEFORE_START_RUNNING
63
+ )
64
+ })
65
+
66
+ test('done task is blocked', () => {
67
+ const e = entity([task({duration: 5})]) // completes at 5s, now=10s
68
+ expect(cancelEligibility(e, 0, 0, {now}).blockedReason).toBe(CancelBlockReason.DONE)
69
+ })
70
+
71
+ test('unknown lane: count 0, ok false', () => {
72
+ const e = entity([task({})])
73
+ const plan = cancelEligibility(e, 9, 0, {now})
74
+ expect(plan.ok).toBe(false)
75
+ expect(plan.range.count).toBe(0)
76
+ })
77
+
78
+ test('multi-task range: 4 tasks, fromIndex 1 yields count 3', () => {
79
+ const e = entity([
80
+ task({duration: 100}),
81
+ task({cancelable: TaskCancelable.ALWAYS, duration: 100}),
82
+ task({cancelable: TaskCancelable.ALWAYS, duration: 100}),
83
+ task({cancelable: TaskCancelable.ALWAYS, duration: 100}),
84
+ ])
85
+ const plan = cancelEligibility(e, 0, 1, {now})
86
+ expect(plan.ok).toBe(true)
87
+ expect(plan.range.count).toBe(3)
88
+ expect(plan.range.taskIndices).toEqual([1, 2, 3])
89
+ })
90
+ })
91
+
92
+ describe('cancelEligibility — linked tasks', () => {
93
+ const now = new Date('2026-06-19T00:00:10.000Z')
94
+ test('range containing a linked (entitygroup) task is blocked', () => {
95
+ const e = entity([task({}), task({group: 42}), task({})])
96
+ expect(cancelEligibility(e, 0, 0, {now}).blockedReason).toBe(
97
+ CancelBlockReason.CONTAINS_LINKED_TASK
98
+ )
99
+ })
100
+ test('task after the linked one cancels normally', () => {
101
+ const e = entity([task({}), task({group: 42}), task({})])
102
+ expect(cancelEligibility(e, 0, 2, {now}).ok).toBe(true)
103
+ })
104
+ })
105
+
106
+ const HOLD_PULL = 1
107
+ const HOLD_BUILD = 4
108
+ function loadTask(giverType: string, giverId: number, holdId: number, qty: number) {
109
+ return ServerContract.Types.task.from({
110
+ type: TaskType.LOAD,
111
+ duration: 100,
112
+ cancelable: TaskCancelable.ALWAYS,
113
+ cargo: [{item_id: 7, stats: 0, modules: [], quantity: qty}],
114
+ couplings: [
115
+ {
116
+ counterpart: {entity_type: giverType, entity_id: giverId},
117
+ hold: holdId,
118
+ kind: HOLD_PULL,
119
+ },
120
+ ],
121
+ })
122
+ }
123
+
124
+ describe('cancelEligibility — effects', () => {
125
+ const now = new Date('2026-06-19T00:00:10.000Z')
126
+
127
+ test('abandonsRunning true when the front of range is a running ALWAYS task', () => {
128
+ const e = entity([
129
+ task({cancelable: TaskCancelable.ALWAYS, type: TaskType.RECHARGE, duration: 100}),
130
+ ])
131
+ expect(cancelEligibility(e, 0, 0, {now}).effects.abandonsRunning).toBe(true)
132
+ })
133
+
134
+ test('buildplot cancel reports keepsPlotDeposits', () => {
135
+ const e = entity([
136
+ ServerContract.Types.task.from({
137
+ type: TaskType.BUILDPLOT,
138
+ duration: 100,
139
+ cancelable: TaskCancelable.ALWAYS,
140
+ cargo: [],
141
+ couplings: [
142
+ {
143
+ counterpart: {entity_type: 'plot', entity_id: 55},
144
+ hold: 0,
145
+ kind: HOLD_BUILD,
146
+ },
147
+ ],
148
+ }),
149
+ ])
150
+ const plan = cancelEligibility(e, 0, 0, {now})
151
+ expect(plan.effects.keepsPlotDeposits?.plot.entity_id.toNumber()).toBe(55)
152
+ })
153
+
154
+ test('PULL load cancel refunds cargo to the giver', () => {
155
+ const lt = loadTask('warehouse', 6, 1, 4)
156
+ const upcoming = new Date('2026-06-18T23:59:50.000Z')
157
+ const e = ServerContract.Types.entity_info.from({
158
+ type: 'ship',
159
+ id: 1,
160
+ owner: 'player.gm',
161
+ entity_name: 'Ship 1',
162
+ coordinates: {x: 0, y: 0, z: 0},
163
+ item_id: 1,
164
+ cargomass: 0,
165
+ cargo: [],
166
+ modules: [],
167
+ lanes: [{lane_key: 0, schedule: {started: T0, tasks: [lt]}}],
168
+ gatherer_lanes: [],
169
+ crafter_lanes: [],
170
+ builder_lanes: [],
171
+ loader_lanes: [],
172
+ holds: [
173
+ {
174
+ id: 1,
175
+ kind: HOLD_PULL,
176
+ counterpart: {entity_type: 'warehouse', entity_id: 6},
177
+ until: T0,
178
+ incoming_mass: 0,
179
+ },
180
+ ],
181
+ })
182
+ const plan = cancelEligibility(e, 0, 0, {now: upcoming})
183
+ expect(plan.effects.refunds[0]?.giver.entity_id.toNumber()).toBe(6)
184
+ expect(plan.effects.refunds[0]?.cargo[0].quantity.toNumber()).toBe(4)
185
+ })
186
+
187
+ test('PULL load cancel populates releasedHolds with kind and counterpart', () => {
188
+ const lt = loadTask('warehouse', 6, 1, 4)
189
+ const upcoming = new Date('2026-06-18T23:59:50.000Z')
190
+ const e = ServerContract.Types.entity_info.from({
191
+ type: 'ship',
192
+ id: 1,
193
+ owner: 'player.gm',
194
+ entity_name: 'Ship 1',
195
+ coordinates: {x: 0, y: 0, z: 0},
196
+ item_id: 1,
197
+ cargomass: 0,
198
+ cargo: [],
199
+ modules: [],
200
+ lanes: [{lane_key: 0, schedule: {started: T0, tasks: [lt]}}],
201
+ gatherer_lanes: [],
202
+ crafter_lanes: [],
203
+ builder_lanes: [],
204
+ loader_lanes: [],
205
+ holds: [
206
+ {
207
+ id: 1,
208
+ kind: HOLD_PULL,
209
+ counterpart: {entity_type: 'warehouse', entity_id: 6},
210
+ until: T0,
211
+ incoming_mass: 0,
212
+ },
213
+ ],
214
+ })
215
+ const plan = cancelEligibility(e, 0, 0, {now: upcoming})
216
+ expect(plan.effects.releasedHolds[0]?.kind).toBe(1)
217
+ expect(plan.effects.releasedHolds[0]?.counterpart.entity_id.toNumber()).toBe(6)
218
+ })
219
+
220
+ test('uncoupled task emits no releasedHolds entry', () => {
221
+ const upcoming = new Date('2026-06-18T23:59:50.000Z')
222
+ const e = ServerContract.Types.entity_info.from({
223
+ type: 'ship',
224
+ id: 1,
225
+ owner: 'player.gm',
226
+ entity_name: 'Ship 1',
227
+ coordinates: {x: 0, y: 0, z: 0},
228
+ item_id: 1,
229
+ cargomass: 0,
230
+ cargo: [],
231
+ modules: [],
232
+ lanes: [
233
+ {
234
+ lane_key: 0,
235
+ schedule: {
236
+ started: T0,
237
+ tasks: [task({cancelable: TaskCancelable.ALWAYS, type: TaskType.LOAD})],
238
+ },
239
+ },
240
+ ],
241
+ gatherer_lanes: [],
242
+ crafter_lanes: [],
243
+ builder_lanes: [],
244
+ loader_lanes: [],
245
+ holds: [],
246
+ })
247
+ const plan = cancelEligibility(e, 0, 0, {now: upcoming})
248
+ expect(plan.effects.releasedHolds).toHaveLength(0)
249
+ expect(plan.effects.refunds).toHaveLength(0)
250
+ })
251
+ })
252
+
253
+ describe('cancelEligibility — feasibility', () => {
254
+ const upcoming = new Date('2026-06-18T23:59:50.000Z')
255
+
256
+ test('WOULD_STRAND when cancelling a producer a later consumer needs', () => {
257
+ const producer = ServerContract.Types.task.from({
258
+ type: TaskType.LOAD,
259
+ duration: 50,
260
+ cancelable: TaskCancelable.ALWAYS,
261
+ cargo: [{item_id: 7, stats: 0, modules: [], quantity: 2}],
262
+ couplings: [],
263
+ })
264
+ const consumer = ServerContract.Types.task.from({
265
+ type: TaskType.CRAFT,
266
+ duration: 50,
267
+ cancelable: TaskCancelable.ALWAYS,
268
+ cargo: [
269
+ {item_id: 7, stats: 0, modules: [], quantity: 2},
270
+ {item_id: 9, stats: 0, modules: [], quantity: 1},
271
+ ],
272
+ couplings: [],
273
+ })
274
+ const e = ServerContract.Types.entity_info.from({
275
+ type: 'ship',
276
+ id: 1,
277
+ owner: 'player.gm',
278
+ entity_name: 'S',
279
+ coordinates: {x: 0, y: 0, z: 0},
280
+ item_id: 1,
281
+ cargomass: 0,
282
+ cargo: [],
283
+ modules: [],
284
+ lanes: [
285
+ {
286
+ lane_key: 1,
287
+ schedule: {started: '2026-06-19T00:00:00', tasks: [producer]},
288
+ },
289
+ {
290
+ lane_key: 2,
291
+ schedule: {started: '2026-06-19T00:00:00', tasks: [consumer]},
292
+ },
293
+ ],
294
+ gatherer_lanes: [],
295
+ crafter_lanes: [],
296
+ builder_lanes: [],
297
+ loader_lanes: [],
298
+ holds: [],
299
+ })
300
+ expect(cancelEligibility(e, 1, 0, {now: upcoming}).blockedReason).toBe(
301
+ CancelBlockReason.WOULD_STRAND
302
+ )
303
+ })
304
+
305
+ test('benign cancel on independent lane does NOT strand', () => {
306
+ const producer = ServerContract.Types.task.from({
307
+ type: TaskType.LOAD,
308
+ duration: 50,
309
+ cancelable: TaskCancelable.ALWAYS,
310
+ cargo: [{item_id: 7, stats: 0, modules: [], quantity: 2}],
311
+ couplings: [],
312
+ })
313
+ const independent = ServerContract.Types.task.from({
314
+ type: TaskType.TRAVEL,
315
+ duration: 50,
316
+ cancelable: TaskCancelable.ALWAYS,
317
+ cargo: [],
318
+ couplings: [],
319
+ })
320
+ const e = ServerContract.Types.entity_info.from({
321
+ type: 'ship',
322
+ id: 1,
323
+ owner: 'player.gm',
324
+ entity_name: 'S',
325
+ coordinates: {x: 0, y: 0, z: 0},
326
+ item_id: 1,
327
+ cargomass: 0,
328
+ cargo: [],
329
+ modules: [],
330
+ lanes: [
331
+ {lane_key: 1, schedule: {started: '2026-06-19T00:00:00', tasks: [producer]}},
332
+ {lane_key: 2, schedule: {started: '2026-06-19T00:00:00', tasks: [independent]}},
333
+ ],
334
+ gatherer_lanes: [],
335
+ crafter_lanes: [],
336
+ builder_lanes: [],
337
+ loader_lanes: [],
338
+ holds: [],
339
+ })
340
+ expect(cancelEligibility(e, 2, 0, {now: upcoming}).ok).toBe(true)
341
+ })
342
+
343
+ test('WOULD_STRAND when cancelling producer of a MODULAR cargo the consumer needs', () => {
344
+ const moduledCargo = {item_id: 7, stats: 0, modules: [{type: 3}], quantity: 2}
345
+ const producer = ServerContract.Types.task.from({
346
+ type: TaskType.LOAD,
347
+ duration: 50,
348
+ cancelable: TaskCancelable.ALWAYS,
349
+ cargo: [moduledCargo],
350
+ couplings: [],
351
+ })
352
+ const consumer = ServerContract.Types.task.from({
353
+ type: TaskType.CRAFT,
354
+ duration: 50,
355
+ cancelable: TaskCancelable.ALWAYS,
356
+ cargo: [moduledCargo, {item_id: 9, stats: 0, modules: [], quantity: 1}],
357
+ couplings: [],
358
+ })
359
+ const e = ServerContract.Types.entity_info.from({
360
+ type: 'ship',
361
+ id: 1,
362
+ owner: 'player.gm',
363
+ entity_name: 'S',
364
+ coordinates: {x: 0, y: 0, z: 0},
365
+ item_id: 1,
366
+ cargomass: 0,
367
+ cargo: [],
368
+ modules: [],
369
+ lanes: [
370
+ {lane_key: 1, schedule: {started: '2026-06-19T00:00:00', tasks: [producer]}},
371
+ {lane_key: 2, schedule: {started: '2026-06-19T00:00:00', tasks: [consumer]}},
372
+ ],
373
+ gatherer_lanes: [],
374
+ crafter_lanes: [],
375
+ builder_lanes: [],
376
+ loader_lanes: [],
377
+ holds: [],
378
+ })
379
+ expect(cancelEligibility(e, 1, 0, {now: upcoming}).blockedReason).toBe(
380
+ CancelBlockReason.WOULD_STRAND
381
+ )
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
+ })