@shipload/sdk 1.0.0-next.7 → 1.0.0-next.70

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 (203) 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 +5696 -1503
  7. package/lib/shipload.js +20294 -5200
  8. package/lib/shipload.js.map +1 -1
  9. package/lib/shipload.m.js +20371 -5683
  10. package/lib/shipload.m.js.map +1 -1
  11. package/lib/testing.d.ts +1648 -0
  12. package/lib/testing.js +6980 -0
  13. package/lib/testing.js.map +1 -0
  14. package/lib/testing.m.js +6974 -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 +80 -0
  20. package/src/capabilities/crafting.ts +50 -8
  21. package/src/capabilities/gathering.test.ts +29 -0
  22. package/src/capabilities/gathering.ts +38 -20
  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.test.ts +26 -0
  28. package/src/capabilities/storage.ts +22 -7
  29. package/src/contracts/fund.ts +306 -0
  30. package/src/contracts/platform.ts +312 -3
  31. package/src/contracts/server.ts +2661 -466
  32. package/src/contracts/token.ts +170 -0
  33. package/src/contracts.ts +2 -0
  34. package/src/coordinates/address.ts +88 -0
  35. package/src/coordinates/constants.test.ts +15 -0
  36. package/src/coordinates/constants.ts +23 -0
  37. package/src/coordinates/index.ts +15 -0
  38. package/src/coordinates/memo.test.ts +47 -0
  39. package/src/coordinates/memo.ts +20 -0
  40. package/src/coordinates/permutation.ts +77 -0
  41. package/src/coordinates/regions.ts +48 -0
  42. package/src/coordinates/sectors.ts +115 -0
  43. package/src/data/capabilities.ts +49 -18
  44. package/src/data/capability-formulas.ts +26 -14
  45. package/src/data/catalog.test.ts +19 -0
  46. package/src/data/catalog.ts +13 -3
  47. package/src/data/charters.json +396 -0
  48. package/src/data/citizenry-adjectives.json +42 -0
  49. package/src/data/citizenry-nouns.json +50 -0
  50. package/src/data/colors.ts +14 -32
  51. package/src/data/entities.json +421 -15
  52. package/src/data/item-ids.ts +53 -13
  53. package/src/data/items.json +330 -77
  54. package/src/data/kind-registry.json +212 -0
  55. package/src/data/kind-registry.ts +192 -0
  56. package/src/data/metadata.ts +312 -51
  57. package/src/data/recipes-runtime.ts +6 -23
  58. package/src/data/recipes.json +2173 -173
  59. package/src/data/tiers.ts +9 -6
  60. package/src/derivation/build-methods.test.ts +71 -0
  61. package/src/derivation/build-methods.ts +50 -0
  62. package/src/derivation/capabilities.test.ts +348 -0
  63. package/src/derivation/capabilities.ts +707 -0
  64. package/src/derivation/capability-mappings.ts +60 -16
  65. package/src/derivation/crafting.test.ts +17 -0
  66. package/src/derivation/crafting.ts +57 -34
  67. package/src/derivation/index.ts +27 -2
  68. package/src/derivation/recipe-usage.test.ts +88 -0
  69. package/src/derivation/recipe-usage.ts +141 -0
  70. package/src/derivation/reserve-regen.ts +34 -0
  71. package/src/derivation/resources.ts +9 -1
  72. package/src/derivation/rollups.test.ts +55 -0
  73. package/src/derivation/rollups.ts +72 -0
  74. package/src/derivation/stars.test.ts +67 -0
  75. package/src/derivation/stars.ts +25 -0
  76. package/src/derivation/stat-scaling.ts +12 -0
  77. package/src/derivation/stats.ts +6 -6
  78. package/src/derivation/strata.ts +5 -3
  79. package/src/derivation/stratum.ts +26 -22
  80. package/src/derivation/tiers.ts +40 -7
  81. package/src/derivation/upgrades.ts +14 -0
  82. package/src/derivation/wormhole.ts +154 -0
  83. package/src/entities/entity.ts +102 -0
  84. package/src/entities/gamestate.ts +3 -28
  85. package/src/entities/makers.ts +144 -136
  86. package/src/entities/slot-multiplier.ts +43 -0
  87. package/src/entity/hydrate.test.ts +156 -0
  88. package/src/entity/hydrate.ts +86 -0
  89. package/src/errors.ts +21 -16
  90. package/src/format.ts +42 -9
  91. package/src/index-module.ts +411 -32
  92. package/src/influence/ballot.test.ts +103 -0
  93. package/src/influence/ballot.ts +98 -0
  94. package/src/influence/categories.ts +32 -0
  95. package/src/influence/charter-metadata.test.ts +136 -0
  96. package/src/influence/charter-metadata.ts +236 -0
  97. package/src/influence/charters.test.ts +112 -0
  98. package/src/influence/charters.ts +150 -0
  99. package/src/influence/citizenry.ts +38 -0
  100. package/src/influence/civic-shuttle.test.ts +202 -0
  101. package/src/influence/civic-shuttle.ts +142 -0
  102. package/src/influence/civic.test.ts +287 -0
  103. package/src/influence/civic.ts +269 -0
  104. package/src/influence/constants.ts +84 -0
  105. package/src/influence/decay.ts +20 -0
  106. package/src/influence/decomp.ts +95 -0
  107. package/src/influence/demand.ts +106 -0
  108. package/src/influence/duration.ts +101 -0
  109. package/src/influence/index.ts +118 -0
  110. package/src/influence/influence.test.ts +450 -0
  111. package/src/influence/quality.ts +25 -0
  112. package/src/influence/valuation.ts +154 -0
  113. package/src/managers/actions.ts +940 -93
  114. package/src/managers/balances.test.ts +161 -0
  115. package/src/managers/balances.ts +93 -0
  116. package/src/managers/base.ts +10 -2
  117. package/src/managers/cluster.test.ts +39 -0
  118. package/src/managers/cluster.ts +53 -0
  119. package/src/managers/construction-types.ts +80 -0
  120. package/src/managers/construction.ts +453 -0
  121. package/src/managers/context.ts +58 -1
  122. package/src/managers/coordinates.ts +14 -0
  123. package/src/managers/entities.ts +35 -63
  124. package/src/managers/epochs.ts +72 -1
  125. package/src/managers/index.ts +38 -1
  126. package/src/managers/influence.test.ts +56 -0
  127. package/src/managers/influence.ts +425 -0
  128. package/src/managers/jobs.test.ts +80 -0
  129. package/src/managers/jobs.ts +54 -0
  130. package/src/managers/locations.ts +25 -29
  131. package/src/managers/nft.test.ts +21 -0
  132. package/src/managers/nft.ts +110 -0
  133. package/src/managers/players.ts +25 -0
  134. package/src/managers/plot.ts +122 -0
  135. package/src/nft/atomicassets.abi.json +1342 -0
  136. package/src/nft/atomicassets.ts +237 -0
  137. package/src/nft/atomicdata.ts +130 -0
  138. package/src/nft/buildImmutableData.ts +388 -0
  139. package/src/nft/description.ts +222 -100
  140. package/src/nft/index.ts +3 -0
  141. package/src/planner/index.ts +296 -0
  142. package/src/planner/planner.test.ts +459 -0
  143. package/src/receipts.ts +22 -0
  144. package/src/resolution/describe-module.ts +43 -25
  145. package/src/resolution/display-name.ts +39 -11
  146. package/src/resolution/resolve-item.test.ts +48 -0
  147. package/src/resolution/resolve-item.ts +112 -58
  148. package/src/scan/index.ts +244 -0
  149. package/src/scan/scan-wasm.base64.ts +2 -0
  150. package/src/scheduling/accessor.ts +65 -23
  151. package/src/scheduling/availability.test.ts +320 -0
  152. package/src/scheduling/availability.ts +251 -0
  153. package/src/scheduling/cancel.test.ts +550 -0
  154. package/src/scheduling/cancel.ts +254 -0
  155. package/src/scheduling/cluster-stock.test.ts +31 -0
  156. package/src/scheduling/cluster-stock.ts +15 -0
  157. package/src/scheduling/energy.ts +47 -0
  158. package/src/scheduling/epoch.ts +1 -0
  159. package/src/scheduling/idle-resolve.ts +45 -0
  160. package/src/scheduling/jobs.test.ts +49 -0
  161. package/src/scheduling/jobs.ts +137 -0
  162. package/src/scheduling/lane-core.ts +128 -0
  163. package/src/scheduling/lanes.test.ts +250 -0
  164. package/src/scheduling/lanes.ts +227 -0
  165. package/src/scheduling/projection.ts +265 -149
  166. package/src/scheduling/schedule.test.ts +163 -0
  167. package/src/scheduling/schedule.ts +282 -116
  168. package/src/scheduling/task-cargo.test.ts +110 -0
  169. package/src/scheduling/task-cargo.ts +62 -0
  170. package/src/scheduling/task-effects.test.ts +35 -0
  171. package/src/scheduling/task-effects.ts +34 -0
  172. package/src/scheduling/unwrap.test.ts +86 -0
  173. package/src/scheduling/unwrap.ts +190 -0
  174. package/src/shipload.ts +63 -4
  175. package/src/subscriptions/manager.cluster.test.ts +51 -0
  176. package/src/subscriptions/manager.ts +245 -172
  177. package/src/subscriptions/mappers.ts +5 -8
  178. package/src/subscriptions/types.ts +28 -3
  179. package/src/testing/catalog-hash.ts +19 -0
  180. package/src/testing/index.ts +2 -0
  181. package/src/testing/projection-parity.ts +167 -0
  182. package/src/travel/reach.test.ts +86 -0
  183. package/src/travel/reach.ts +34 -0
  184. package/src/travel/route-build.test.ts +63 -0
  185. package/src/travel/route-build.ts +60 -0
  186. package/src/travel/route-planner.ts +288 -0
  187. package/src/travel/route-simulator.ts +170 -0
  188. package/src/travel/travel.ts +190 -123
  189. package/src/types/capabilities.ts +37 -8
  190. package/src/types/entity.ts +3 -3
  191. package/src/types/index.ts +0 -1
  192. package/src/types.ts +81 -23
  193. package/src/utils/cargo.test.ts +14 -0
  194. package/src/utils/cargo.ts +2 -0
  195. package/src/utils/display-name.ts +70 -0
  196. package/src/utils/hash.ts +22 -2
  197. package/src/utils/system.ts +24 -5
  198. package/src/capabilities/loading.ts +0 -8
  199. package/src/entities/container.ts +0 -123
  200. package/src/entities/ship-deploy.ts +0 -295
  201. package/src/entities/ship.ts +0 -221
  202. package/src/entities/warehouse.ts +0 -127
  203. package/src/types/entity-traits.ts +0 -69
@@ -0,0 +1,550 @@
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
+ projected_at: 0,
22
+ type: 'ship',
23
+ id: 1,
24
+ owner: 'player.gm',
25
+ entity_name: 'Ship 1',
26
+ coordinates: {x: 0, y: 0, z: 0},
27
+ item_id: 1,
28
+ cargomass: 0,
29
+ cargo: [],
30
+ modules: [],
31
+ lanes: [{lane_key: 0, schedule: {started: startedISO, tasks}}],
32
+ gatherer_lanes: [],
33
+ crafter_lanes: [],
34
+ builder_lanes: [],
35
+ loader_lanes: [],
36
+ holds: [],
37
+ })
38
+ }
39
+
40
+ describe('cancelEligibility — local gates', () => {
41
+ const now = new Date('2026-06-19T00:00:10.000Z') // 10s in: task 0 running, task 1 upcoming
42
+
43
+ test('cancelling the last upcoming task: ok, count 1', () => {
44
+ const e = entity([task({}), task({})])
45
+ const plan = cancelEligibility(e, 0, 1, {now})
46
+ expect(plan.ok).toBe(true)
47
+ expect(plan.range.count).toBe(1)
48
+ expect(plan.range.taskIndices).toEqual([1])
49
+ })
50
+
51
+ test('NEVER task is blocked', () => {
52
+ const e = entity([
53
+ task({}),
54
+ task({cancelable: TaskCancelable.NEVER, type: TaskType.GATHER}),
55
+ ])
56
+ expect(cancelEligibility(e, 0, 1, {now}).blockedReason).toBe(CancelBlockReason.TASK_NEVER)
57
+ })
58
+
59
+ test('BEFORE_START task that is running is blocked', () => {
60
+ const e = entity([task({cancelable: TaskCancelable.BEFORE_START, duration: 100})])
61
+ // task 0 is running at now=10s
62
+ expect(cancelEligibility(e, 0, 0, {now}).blockedReason).toBe(
63
+ CancelBlockReason.BEFORE_START_RUNNING
64
+ )
65
+ })
66
+
67
+ test('done task is blocked', () => {
68
+ const e = entity([task({duration: 5})]) // completes at 5s, now=10s
69
+ expect(cancelEligibility(e, 0, 0, {now}).blockedReason).toBe(CancelBlockReason.DONE)
70
+ })
71
+
72
+ test('unknown lane: count 0, ok false', () => {
73
+ const e = entity([task({})])
74
+ const plan = cancelEligibility(e, 9, 0, {now})
75
+ expect(plan.ok).toBe(false)
76
+ expect(plan.range.count).toBe(0)
77
+ })
78
+
79
+ test('multi-task range: 4 tasks, fromIndex 1 yields count 3', () => {
80
+ const e = entity([
81
+ task({duration: 100}),
82
+ task({cancelable: TaskCancelable.ALWAYS, duration: 100}),
83
+ task({cancelable: TaskCancelable.ALWAYS, duration: 100}),
84
+ task({cancelable: TaskCancelable.ALWAYS, duration: 100}),
85
+ ])
86
+ const plan = cancelEligibility(e, 0, 1, {now})
87
+ expect(plan.ok).toBe(true)
88
+ expect(plan.range.count).toBe(3)
89
+ expect(plan.range.taskIndices).toEqual([1, 2, 3])
90
+ })
91
+ })
92
+
93
+ describe('cancelEligibility — linked tasks', () => {
94
+ const now = new Date('2026-06-19T00:00:10.000Z')
95
+ test('range containing a linked (entitygroup) task is blocked', () => {
96
+ const e = entity([task({}), task({group: 42}), task({})])
97
+ expect(cancelEligibility(e, 0, 0, {now}).blockedReason).toBe(
98
+ CancelBlockReason.CONTAINS_LINKED_TASK
99
+ )
100
+ })
101
+ test('task after the linked one cancels normally', () => {
102
+ const e = entity([task({}), task({group: 42}), task({})])
103
+ expect(cancelEligibility(e, 0, 2, {now}).ok).toBe(true)
104
+ })
105
+ })
106
+
107
+ const HOLD_PULL = 1
108
+ const HOLD_BUILD = 4
109
+ function loadTask(giverType: string, giverId: number, holdId: number, qty: number) {
110
+ return ServerContract.Types.task.from({
111
+ type: TaskType.LOAD,
112
+ duration: 100,
113
+ cancelable: TaskCancelable.ALWAYS,
114
+ cargo: [{item_id: 7, stats: 0, modules: [], quantity: qty}],
115
+ couplings: [
116
+ {
117
+ counterpart: {entity_type: giverType, entity_id: giverId},
118
+ hold: holdId,
119
+ kind: HOLD_PULL,
120
+ },
121
+ ],
122
+ })
123
+ }
124
+
125
+ describe('cancelEligibility — effects', () => {
126
+ const now = new Date('2026-06-19T00:00:10.000Z')
127
+
128
+ test('abandonsRunning true when the front of range is a running ALWAYS task', () => {
129
+ const e = entity([
130
+ task({cancelable: TaskCancelable.ALWAYS, type: TaskType.RECHARGE, duration: 100}),
131
+ ])
132
+ expect(cancelEligibility(e, 0, 0, {now}).effects.abandonsRunning).toBe(true)
133
+ })
134
+
135
+ test('buildplot cancel reports keepsPlotDeposits', () => {
136
+ const e = entity([
137
+ ServerContract.Types.task.from({
138
+ type: TaskType.BUILDPLOT,
139
+ duration: 100,
140
+ cancelable: TaskCancelable.ALWAYS,
141
+ cargo: [],
142
+ couplings: [
143
+ {
144
+ counterpart: {entity_type: 'plot', entity_id: 55},
145
+ hold: 0,
146
+ kind: HOLD_BUILD,
147
+ },
148
+ ],
149
+ }),
150
+ ])
151
+ const plan = cancelEligibility(e, 0, 0, {now})
152
+ expect(plan.effects.keepsPlotDeposits?.plot.entity_id.toNumber()).toBe(55)
153
+ })
154
+
155
+ test('PULL load cancel refunds cargo to the giver', () => {
156
+ const lt = loadTask('warehouse', 6, 1, 4)
157
+ const upcoming = new Date('2026-06-18T23:59:50.000Z')
158
+ const e = ServerContract.Types.entity_info.from({
159
+ projected_at: 0,
160
+ type: 'ship',
161
+ id: 1,
162
+ owner: 'player.gm',
163
+ entity_name: 'Ship 1',
164
+ coordinates: {x: 0, y: 0, z: 0},
165
+ item_id: 1,
166
+ cargomass: 0,
167
+ cargo: [],
168
+ modules: [],
169
+ lanes: [{lane_key: 0, schedule: {started: T0, tasks: [lt]}}],
170
+ gatherer_lanes: [],
171
+ crafter_lanes: [],
172
+ builder_lanes: [],
173
+ loader_lanes: [],
174
+ holds: [
175
+ {
176
+ id: 1,
177
+ kind: HOLD_PULL,
178
+ counterpart: {entity_type: 'warehouse', entity_id: 6},
179
+ until: T0,
180
+ incoming_mass: 0,
181
+ },
182
+ ],
183
+ })
184
+ const plan = cancelEligibility(e, 0, 0, {now: upcoming})
185
+ expect(plan.effects.refunds[0]?.giver.entity_id.toNumber()).toBe(6)
186
+ expect(plan.effects.refunds[0]?.cargo[0].quantity.toNumber()).toBe(4)
187
+ })
188
+
189
+ test('PULL load cancel populates releasedHolds with kind and counterpart', () => {
190
+ const lt = loadTask('warehouse', 6, 1, 4)
191
+ const upcoming = new Date('2026-06-18T23:59:50.000Z')
192
+ const e = ServerContract.Types.entity_info.from({
193
+ projected_at: 0,
194
+ type: 'ship',
195
+ id: 1,
196
+ owner: 'player.gm',
197
+ entity_name: 'Ship 1',
198
+ coordinates: {x: 0, y: 0, z: 0},
199
+ item_id: 1,
200
+ cargomass: 0,
201
+ cargo: [],
202
+ modules: [],
203
+ lanes: [{lane_key: 0, schedule: {started: T0, tasks: [lt]}}],
204
+ gatherer_lanes: [],
205
+ crafter_lanes: [],
206
+ builder_lanes: [],
207
+ loader_lanes: [],
208
+ holds: [
209
+ {
210
+ id: 1,
211
+ kind: HOLD_PULL,
212
+ counterpart: {entity_type: 'warehouse', entity_id: 6},
213
+ until: T0,
214
+ incoming_mass: 0,
215
+ },
216
+ ],
217
+ })
218
+ const plan = cancelEligibility(e, 0, 0, {now: upcoming})
219
+ expect(plan.effects.releasedHolds[0]?.kind).toBe(1)
220
+ expect(plan.effects.releasedHolds[0]?.counterpart.entity_id.toNumber()).toBe(6)
221
+ })
222
+
223
+ test('uncoupled task emits no releasedHolds entry', () => {
224
+ const upcoming = new Date('2026-06-18T23:59:50.000Z')
225
+ const e = ServerContract.Types.entity_info.from({
226
+ projected_at: 0,
227
+ type: 'ship',
228
+ id: 1,
229
+ owner: 'player.gm',
230
+ entity_name: 'Ship 1',
231
+ coordinates: {x: 0, y: 0, z: 0},
232
+ item_id: 1,
233
+ cargomass: 0,
234
+ cargo: [],
235
+ modules: [],
236
+ lanes: [
237
+ {
238
+ lane_key: 0,
239
+ schedule: {
240
+ started: T0,
241
+ tasks: [task({cancelable: TaskCancelable.ALWAYS, type: TaskType.LOAD})],
242
+ },
243
+ },
244
+ ],
245
+ gatherer_lanes: [],
246
+ crafter_lanes: [],
247
+ builder_lanes: [],
248
+ loader_lanes: [],
249
+ holds: [],
250
+ })
251
+ const plan = cancelEligibility(e, 0, 0, {now: upcoming})
252
+ expect(plan.effects.releasedHolds).toHaveLength(0)
253
+ expect(plan.effects.refunds).toHaveLength(0)
254
+ })
255
+ })
256
+
257
+ describe('cancelEligibility — feasibility', () => {
258
+ const upcoming = new Date('2026-06-18T23:59:50.000Z')
259
+
260
+ test('WOULD_STRAND when cancelling a producer a later consumer needs', () => {
261
+ const producer = ServerContract.Types.task.from({
262
+ type: TaskType.LOAD,
263
+ duration: 50,
264
+ cancelable: TaskCancelable.ALWAYS,
265
+ cargo: [{item_id: 7, stats: 0, modules: [], quantity: 2}],
266
+ couplings: [],
267
+ })
268
+ const consumer = ServerContract.Types.task.from({
269
+ type: TaskType.CRAFT,
270
+ duration: 50,
271
+ cancelable: TaskCancelable.ALWAYS,
272
+ cargo: [
273
+ {item_id: 7, stats: 0, modules: [], quantity: 2},
274
+ {item_id: 9, stats: 0, modules: [], quantity: 1},
275
+ ],
276
+ couplings: [],
277
+ })
278
+ const e = ServerContract.Types.entity_info.from({
279
+ projected_at: 0,
280
+ type: 'ship',
281
+ id: 1,
282
+ owner: 'player.gm',
283
+ entity_name: 'S',
284
+ coordinates: {x: 0, y: 0, z: 0},
285
+ item_id: 1,
286
+ cargomass: 0,
287
+ cargo: [],
288
+ modules: [],
289
+ lanes: [
290
+ {
291
+ lane_key: 1,
292
+ schedule: {started: '2026-06-19T00:00:00', tasks: [producer]},
293
+ },
294
+ {
295
+ lane_key: 2,
296
+ schedule: {started: '2026-06-19T00:00:00', tasks: [consumer]},
297
+ },
298
+ ],
299
+ gatherer_lanes: [],
300
+ crafter_lanes: [],
301
+ builder_lanes: [],
302
+ loader_lanes: [],
303
+ holds: [],
304
+ })
305
+ expect(cancelEligibility(e, 1, 0, {now: upcoming}).blockedReason).toBe(
306
+ CancelBlockReason.WOULD_STRAND
307
+ )
308
+ })
309
+
310
+ test('benign cancel on independent lane does NOT strand', () => {
311
+ const producer = ServerContract.Types.task.from({
312
+ type: TaskType.LOAD,
313
+ duration: 50,
314
+ cancelable: TaskCancelable.ALWAYS,
315
+ cargo: [{item_id: 7, stats: 0, modules: [], quantity: 2}],
316
+ couplings: [],
317
+ })
318
+ const independent = ServerContract.Types.task.from({
319
+ type: TaskType.TRAVEL,
320
+ duration: 50,
321
+ cancelable: TaskCancelable.ALWAYS,
322
+ cargo: [],
323
+ couplings: [],
324
+ })
325
+ const e = ServerContract.Types.entity_info.from({
326
+ projected_at: 0,
327
+ type: 'ship',
328
+ id: 1,
329
+ owner: 'player.gm',
330
+ entity_name: 'S',
331
+ coordinates: {x: 0, y: 0, z: 0},
332
+ item_id: 1,
333
+ cargomass: 0,
334
+ cargo: [],
335
+ modules: [],
336
+ lanes: [
337
+ {lane_key: 1, schedule: {started: '2026-06-19T00:00:00', tasks: [producer]}},
338
+ {lane_key: 2, schedule: {started: '2026-06-19T00:00:00', tasks: [independent]}},
339
+ ],
340
+ gatherer_lanes: [],
341
+ crafter_lanes: [],
342
+ builder_lanes: [],
343
+ loader_lanes: [],
344
+ holds: [],
345
+ })
346
+ expect(cancelEligibility(e, 2, 0, {now: upcoming}).ok).toBe(true)
347
+ })
348
+
349
+ test('WOULD_STRAND when cancelling producer of a MODULAR cargo the consumer needs', () => {
350
+ const moduledCargo = {item_id: 7, stats: 0, modules: [{type: 3}], quantity: 2}
351
+ const producer = ServerContract.Types.task.from({
352
+ type: TaskType.LOAD,
353
+ duration: 50,
354
+ cancelable: TaskCancelable.ALWAYS,
355
+ cargo: [moduledCargo],
356
+ couplings: [],
357
+ })
358
+ const consumer = ServerContract.Types.task.from({
359
+ type: TaskType.CRAFT,
360
+ duration: 50,
361
+ cancelable: TaskCancelable.ALWAYS,
362
+ cargo: [moduledCargo, {item_id: 9, stats: 0, modules: [], quantity: 1}],
363
+ couplings: [],
364
+ })
365
+ const e = ServerContract.Types.entity_info.from({
366
+ projected_at: 0,
367
+ type: 'ship',
368
+ id: 1,
369
+ owner: 'player.gm',
370
+ entity_name: 'S',
371
+ coordinates: {x: 0, y: 0, z: 0},
372
+ item_id: 1,
373
+ cargomass: 0,
374
+ cargo: [],
375
+ modules: [],
376
+ lanes: [
377
+ {lane_key: 1, schedule: {started: '2026-06-19T00:00:00', tasks: [producer]}},
378
+ {lane_key: 2, schedule: {started: '2026-06-19T00:00:00', tasks: [consumer]}},
379
+ ],
380
+ gatherer_lanes: [],
381
+ crafter_lanes: [],
382
+ builder_lanes: [],
383
+ loader_lanes: [],
384
+ holds: [],
385
+ })
386
+ expect(cancelEligibility(e, 1, 0, {now: upcoming}).blockedReason).toBe(
387
+ CancelBlockReason.WOULD_STRAND
388
+ )
389
+ })
390
+ })
391
+
392
+ describe('cancelEligibility — cross-entity strand (counterpart queued consumer)', () => {
393
+ const upcoming = new Date('2026-06-18T23:59:50.000Z')
394
+
395
+ function entityWithId(
396
+ id: number,
397
+ tasks: ReturnType<typeof ServerContract.Types.task.from>[],
398
+ cargo: {
399
+ id: number
400
+ item_id: number
401
+ stats: number
402
+ modules: never[]
403
+ quantity: number
404
+ }[] = []
405
+ ) {
406
+ return ServerContract.Types.entity_info.from({
407
+ projected_at: 0,
408
+ type: 'ship',
409
+ id,
410
+ owner: 'player.gm',
411
+ entity_name: `Ship ${id}`,
412
+ coordinates: {x: 0, y: 0, z: 0},
413
+ item_id: 1,
414
+ cargomass: 0,
415
+ cargo,
416
+ modules: [],
417
+ lanes: [{lane_key: 0, schedule: {started: T0, tasks}}],
418
+ gatherer_lanes: [],
419
+ crafter_lanes: [],
420
+ builder_lanes: [],
421
+ loader_lanes: [],
422
+ holds: [],
423
+ })
424
+ }
425
+
426
+ function pushTask() {
427
+ return ServerContract.Types.task.from({
428
+ type: TaskType.UNLOAD,
429
+ duration: 50,
430
+ cancelable: TaskCancelable.ALWAYS,
431
+ cargo: [{item_id: 7, stats: 0, modules: [], quantity: 2}],
432
+ couplings: [
433
+ {counterpart: {entity_type: 'ship', entity_id: 2}, hold: 1, kind: HoldKind.PUSH},
434
+ ],
435
+ })
436
+ }
437
+
438
+ function craftConsumerTask() {
439
+ return ServerContract.Types.task.from({
440
+ type: TaskType.CRAFT,
441
+ duration: 100,
442
+ cancelable: TaskCancelable.ALWAYS,
443
+ cargo: [
444
+ {item_id: 7, stats: 0, modules: [], quantity: 2},
445
+ {item_id: 9, stats: 0, modules: [], quantity: 1},
446
+ ],
447
+ couplings: [],
448
+ })
449
+ }
450
+
451
+ function incoming(): IncomingSource[] {
452
+ return [
453
+ {
454
+ holdId: '1',
455
+ until: new Date('2026-06-19T00:00:50.000Z'),
456
+ items: [
457
+ ServerContract.Types.cargo_item.from({
458
+ item_id: 7,
459
+ stats: 0,
460
+ modules: [],
461
+ quantity: 2,
462
+ }),
463
+ ],
464
+ },
465
+ ]
466
+ }
467
+
468
+ function upgradeConsumerTask() {
469
+ return ServerContract.Types.task.from({
470
+ type: TaskType.UPGRADE,
471
+ duration: 100,
472
+ cancelable: TaskCancelable.ALWAYS,
473
+ cargo: [{item_id: 7, stats: 0, modules: [], quantity: 2}],
474
+ couplings: [],
475
+ })
476
+ }
477
+
478
+ test('blocked: counterpart consumer loses coverage without this delivery', () => {
479
+ const producer = entityWithId(1, [pushTask()])
480
+ const counterpart = entityWithId(2, [craftConsumerTask()])
481
+ const plan = cancelEligibility(producer, 0, 0, {
482
+ now: upcoming,
483
+ counterparts: new Map([['2', counterpart]]),
484
+ counterpartIncoming: new Map([['2', incoming()]]),
485
+ })
486
+ expect(plan.ok).toBe(false)
487
+ expect(plan.blockedReason).toBe(CancelBlockReason.WOULD_STRAND_COUNTERPART)
488
+ expect(plan.blockedByCounterpart?.entity_id.toNumber()).toBe(2)
489
+ })
490
+
491
+ test('blocked: counterpart upgrade consumer loses coverage without this delivery', () => {
492
+ const producer = entityWithId(1, [pushTask()])
493
+ const counterpart = entityWithId(2, [upgradeConsumerTask()])
494
+ const plan = cancelEligibility(producer, 0, 0, {
495
+ now: upcoming,
496
+ counterparts: new Map([['2', counterpart]]),
497
+ counterpartIncoming: new Map([['2', incoming()]]),
498
+ })
499
+ expect(plan.ok).toBe(false)
500
+ expect(plan.blockedReason).toBe(CancelBlockReason.WOULD_STRAND_COUNTERPART)
501
+ expect(plan.blockedByCounterpart?.entity_id.toNumber()).toBe(2)
502
+ })
503
+
504
+ test('allowed: counterpart upgrade consumer already covered on-hand', () => {
505
+ const producer = entityWithId(1, [pushTask()])
506
+ const counterpart = entityWithId(
507
+ 2,
508
+ [upgradeConsumerTask()],
509
+ [{id: 1, item_id: 7, stats: 0, modules: [], quantity: 2}]
510
+ )
511
+ const plan = cancelEligibility(producer, 0, 0, {
512
+ now: upcoming,
513
+ counterparts: new Map([['2', counterpart]]),
514
+ counterpartIncoming: new Map([['2', incoming()]]),
515
+ })
516
+ expect(plan.ok).toBe(true)
517
+ })
518
+
519
+ test('allowed: counterpart consumer already covered on-hand (surplus coverage)', () => {
520
+ const producer = entityWithId(1, [pushTask()])
521
+ const counterpart = entityWithId(
522
+ 2,
523
+ [craftConsumerTask()],
524
+ [{id: 1, item_id: 7, stats: 0, modules: [], quantity: 2}]
525
+ )
526
+ const plan = cancelEligibility(producer, 0, 0, {
527
+ now: upcoming,
528
+ counterparts: new Map([['2', counterpart]]),
529
+ counterpartIncoming: new Map([['2', incoming()]]),
530
+ })
531
+ expect(plan.ok).toBe(true)
532
+ })
533
+
534
+ test('allowed: counterpart data not loaded, check skipped gracefully', () => {
535
+ const producer = entityWithId(1, [pushTask()])
536
+ const plan = cancelEligibility(producer, 0, 0, {now: upcoming})
537
+ expect(plan.ok).toBe(true)
538
+ })
539
+
540
+ test('allowed: counterpart has no queued consumer', () => {
541
+ const producer = entityWithId(1, [pushTask()])
542
+ const idleCounterpart = entityWithId(2, [])
543
+ const plan = cancelEligibility(producer, 0, 0, {
544
+ now: upcoming,
545
+ counterparts: new Map([['2', idleCounterpart]]),
546
+ counterpartIncoming: new Map([['2', incoming()]]),
547
+ })
548
+ expect(plan.ok).toBe(true)
549
+ })
550
+ })