@shipload/sdk 1.0.0-next.34 → 1.0.0-next.36

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 (59) hide show
  1. package/lib/shipload.d.ts +398 -51
  2. package/lib/shipload.js +1481 -400
  3. package/lib/shipload.js.map +1 -1
  4. package/lib/shipload.m.js +1442 -401
  5. package/lib/shipload.m.js.map +1 -1
  6. package/lib/testing.d.ts +101 -20
  7. package/lib/testing.js +201 -57
  8. package/lib/testing.js.map +1 -1
  9. package/lib/testing.m.js +201 -57
  10. package/lib/testing.m.js.map +1 -1
  11. package/package.json +1 -1
  12. package/src/capabilities/crafting.ts +2 -3
  13. package/src/capabilities/gathering.test.ts +16 -0
  14. package/src/capabilities/gathering.ts +8 -11
  15. package/src/contracts/server.ts +147 -29
  16. package/src/coordinates/address.ts +88 -0
  17. package/src/coordinates/constants.test.ts +15 -0
  18. package/src/coordinates/constants.ts +23 -0
  19. package/src/coordinates/index.ts +15 -0
  20. package/src/coordinates/memo.test.ts +47 -0
  21. package/src/coordinates/memo.ts +20 -0
  22. package/src/coordinates/permutation.ts +77 -0
  23. package/src/coordinates/regions.ts +48 -0
  24. package/src/coordinates/sectors.ts +115 -0
  25. package/src/data/capability-formulas.ts +0 -1
  26. package/src/data/entities.json +4 -4
  27. package/src/data/items.json +5 -5
  28. package/src/data/recipes.json +39 -65
  29. package/src/derivation/capabilities.test.ts +133 -0
  30. package/src/derivation/capabilities.ts +66 -14
  31. package/src/derivation/rollups.test.ts +55 -0
  32. package/src/derivation/rollups.ts +56 -0
  33. package/src/derivation/wormhole.ts +115 -0
  34. package/src/entities/makers.ts +30 -3
  35. package/src/errors.ts +2 -0
  36. package/src/index-module.ts +38 -2
  37. package/src/managers/actions.ts +79 -5
  38. package/src/managers/construction.ts +6 -4
  39. package/src/managers/context.ts +9 -0
  40. package/src/managers/coordinates.ts +14 -0
  41. package/src/managers/plot.ts +2 -4
  42. package/src/nft/description.ts +25 -6
  43. package/src/planner/index.ts +127 -0
  44. package/src/planner/planner.test.ts +319 -0
  45. package/src/resolution/resolve-item.ts +4 -1
  46. package/src/scheduling/availability.ts +1 -1
  47. package/src/scheduling/cancel.test.ts +348 -0
  48. package/src/scheduling/cancel.ts +209 -0
  49. package/src/scheduling/lanes.test.ts +249 -0
  50. package/src/scheduling/lanes.ts +140 -2
  51. package/src/scheduling/projection.ts +75 -16
  52. package/src/scheduling/schedule.ts +3 -1
  53. package/src/shipload.ts +5 -0
  54. package/src/testing/projection-parity.ts +26 -2
  55. package/src/travel/travel.ts +116 -105
  56. package/src/types/capabilities.ts +23 -6
  57. package/src/types/entity.ts +3 -3
  58. package/src/types.ts +2 -1
  59. package/src/utils/system.ts +11 -0
@@ -0,0 +1,319 @@
1
+ import {UInt8, UInt16, UInt32, UInt64} from '@wharfkit/antelope'
2
+ import {expect, test, describe} from 'bun:test'
3
+ import type {GathererStats} from '../types/capabilities'
4
+ import {ServerContract} from '../contracts'
5
+ import {calc_gather_duration} from '../capabilities/gathering'
6
+ import {planParallelGather, planParallelTransfer, type GatherPlanEntity} from './index'
7
+
8
+ function gathererLane(
9
+ slotIndex: number,
10
+ yieldVal: number,
11
+ drain: number,
12
+ depth: number
13
+ ): ServerContract.Types.gatherer_lane {
14
+ return ServerContract.Types.gatherer_lane.from({
15
+ slot_index: UInt8.from(slotIndex),
16
+ yield: UInt16.from(yieldVal),
17
+ drain: UInt32.from(drain),
18
+ depth: UInt16.from(depth),
19
+ output_pct: UInt16.from(100),
20
+ })
21
+ }
22
+
23
+ function loaderLane(
24
+ slotIndex: number,
25
+ mass: number,
26
+ thrust: number
27
+ ): ServerContract.Types.loader_lane {
28
+ return ServerContract.Types.loader_lane.from({
29
+ slot_index: UInt8.from(slotIndex),
30
+ mass: UInt32.from(mass),
31
+ thrust: UInt16.from(thrust),
32
+ output_pct: UInt16.from(100),
33
+ })
34
+ }
35
+
36
+ function energyStats(capacity: number, recharge: number): ServerContract.Types.energy_stats {
37
+ return ServerContract.Types.energy_stats.from({
38
+ capacity: UInt32.from(capacity),
39
+ recharge: UInt32.from(recharge),
40
+ })
41
+ }
42
+
43
+ interface EntityOverrides {
44
+ gatherer_lanes?: ServerContract.Types.gatherer_lane[]
45
+ loader_lanes?: ServerContract.Types.loader_lane[]
46
+ generator?: ServerContract.Types.energy_stats
47
+ energy?: number
48
+ lanes?: ServerContract.Types.lane[]
49
+ }
50
+
51
+ function entity(overrides: EntityOverrides = {}): GatherPlanEntity {
52
+ return {
53
+ gatherer_lanes: overrides.gatherer_lanes ?? [],
54
+ loader_lanes: overrides.loader_lanes ?? [],
55
+ generator: overrides.generator,
56
+ energy: overrides.energy !== undefined ? UInt16.from(overrides.energy) : undefined,
57
+ lanes: overrides.lanes ?? [],
58
+ coordinates: ServerContract.Types.coordinates.from({x: 0, y: 0}),
59
+ cargo: [],
60
+ cargomass: UInt32.from(0),
61
+ }
62
+ }
63
+
64
+ const NOW = new Date('2026-06-21T00:00:00.000Z')
65
+
66
+ describe('planParallelGather', () => {
67
+ test('sanity: single-gatherer qty 20 = ~35s matches calc_gather_duration', () => {
68
+ const gatherer: GathererStats = {
69
+ yield: UInt16.from(57),
70
+ drain: UInt32.from(500),
71
+ depth: UInt16.from(5000),
72
+ }
73
+ const dur = calc_gather_duration(gatherer, 228, 20, 0, 1000)
74
+ expect(Number(dur)).toBeCloseTo(35, 0)
75
+ })
76
+
77
+ test('two gatherers: quantities proportional to yield, durations within 1s', () => {
78
+ const YIELD1 = 200
79
+ const YIELD2 = 400
80
+ const DEPTH = 5000
81
+ const DRAIN = 500
82
+ const QUANTITY = 60
83
+ const STRATUM = 0
84
+
85
+ const e = entity({
86
+ gatherer_lanes: [
87
+ gathererLane(0, YIELD1, DRAIN, DEPTH),
88
+ gathererLane(1, YIELD2, DRAIN, DEPTH),
89
+ ],
90
+ generator: energyStats(10000, 100),
91
+ energy: 10000,
92
+ })
93
+
94
+ const plan = planParallelGather(e, {quantity: QUANTITY}, STRATUM, NOW)
95
+
96
+ expect(plan).toHaveLength(2)
97
+ expect(plan.reduce((s, p) => s + p.quantity, 0)).toBe(QUANTITY)
98
+
99
+ const q1 = plan.find((p) => p.slot === 0)!.quantity
100
+ const q2 = plan.find((p) => p.slot === 1)!.quantity
101
+ expect(q1 + q2).toBe(QUANTITY)
102
+ expect(q2 / q1).toBeCloseTo(YIELD2 / YIELD1, 0)
103
+
104
+ const ITEM_MASS = 228
105
+ const RICHNESS = 1000
106
+ const g1: GathererStats = {
107
+ yield: UInt16.from(YIELD1),
108
+ drain: UInt32.from(DRAIN),
109
+ depth: UInt16.from(DEPTH),
110
+ }
111
+ const g2: GathererStats = {
112
+ yield: UInt16.from(YIELD2),
113
+ drain: UInt32.from(DRAIN),
114
+ depth: UInt16.from(DEPTH),
115
+ }
116
+ const dur1 = Number(calc_gather_duration(g1, ITEM_MASS, q1, STRATUM, RICHNESS))
117
+ const dur2 = Number(calc_gather_duration(g2, ITEM_MASS, q2, STRATUM, RICHNESS))
118
+ expect(Math.abs(dur1 - dur2)).toBeLessThan(1)
119
+ })
120
+
121
+ test("'max' target: uses all reaching lanes, each slot gets >= 1 unit", () => {
122
+ const e = entity({
123
+ gatherer_lanes: [gathererLane(0, 200, 500, 5000), gathererLane(1, 300, 500, 5000)],
124
+ generator: energyStats(10000, 100),
125
+ energy: 10000,
126
+ })
127
+
128
+ const plan = planParallelGather(e, 'max', 0, NOW)
129
+
130
+ expect(plan.length).toBeGreaterThan(0)
131
+ for (const entry of plan) {
132
+ expect(entry.quantity).toBeGreaterThanOrEqual(1)
133
+ }
134
+ })
135
+
136
+ test('energy-starved: drops the lowest-yield lane(s) until the pool sustains the plan', () => {
137
+ // Full-Q energy: 3 lanes=>144, 2 lanes(drop yield=50)=>120; pool 130 fits 2 but not 3.
138
+ const e = entity({
139
+ gatherer_lanes: [
140
+ gathererLane(0, 50, 10000, 5000),
141
+ gathererLane(1, 100, 10000, 5000),
142
+ gathererLane(2, 100, 10000, 5000),
143
+ ],
144
+ generator: energyStats(10000, 1),
145
+ energy: 130,
146
+ })
147
+
148
+ const plan = planParallelGather(e, {quantity: 120}, 0, NOW)
149
+
150
+ // Lowest-yield lane (slot 0) dropped; the two yield-100 lanes survive.
151
+ expect(plan).toHaveLength(2)
152
+ expect(plan.find((p) => p.slot === 0)).toBeUndefined()
153
+ expect(plan.reduce((s, p) => s + p.quantity, 0)).toBe(120)
154
+ })
155
+
156
+ test('energy-starved: single lane caps quantity to the sustainable max', () => {
157
+ // energyPerUnit=1, full Q=120 costs 120 > pool 50, so quantity caps at 50.
158
+ const e = entity({
159
+ gatherer_lanes: [gathererLane(0, 100, 10000, 5000)],
160
+ generator: energyStats(10000, 1),
161
+ energy: 50,
162
+ })
163
+
164
+ const plan = planParallelGather(e, {quantity: 120}, 0, NOW)
165
+
166
+ expect(plan).toHaveLength(1)
167
+ expect(plan[0].slot).toBe(0)
168
+ expect(plan[0].quantity).toBe(50)
169
+ })
170
+
171
+ test('energy-starved: projected energy nets out a queued gather task', () => {
172
+ // A queued task costing 9970 leaves 30 projected energy => quantity caps at 30.
173
+ const queued = ServerContract.Types.lane.from({
174
+ lane_key: UInt8.from(0),
175
+ schedule: {
176
+ started: NOW.toISOString().slice(0, -1),
177
+ tasks: [
178
+ ServerContract.Types.task.from({
179
+ type: UInt8.from(5),
180
+ duration: UInt32.from(100),
181
+ cancelable: 0,
182
+ cargo: [],
183
+ entitytarget: {entity_type: 'ship', entity_id: UInt64.from(1)},
184
+ energy_cost: UInt32.from(9970),
185
+ }),
186
+ ],
187
+ },
188
+ })
189
+
190
+ const e = entity({
191
+ gatherer_lanes: [gathererLane(0, 100, 10000, 5000)],
192
+ generator: energyStats(10000, 1),
193
+ energy: 10000,
194
+ lanes: [queued],
195
+ })
196
+
197
+ const plan = planParallelGather(e, {quantity: 120}, 0, NOW)
198
+
199
+ expect(plan).toHaveLength(1)
200
+ expect(plan[0].quantity).toBe(30)
201
+ })
202
+
203
+ test('stratum filter: shallow lane excluded, only deep lane used', () => {
204
+ const e = entity({
205
+ gatherer_lanes: [gathererLane(0, 200, 500, 500), gathererLane(1, 300, 500, 5000)],
206
+ generator: energyStats(10000, 100),
207
+ energy: 10000,
208
+ })
209
+
210
+ const plan = planParallelGather(e, {quantity: 10}, 2000, NOW)
211
+
212
+ expect(plan).toHaveLength(1)
213
+ expect(plan[0].slot).toBe(1)
214
+ })
215
+
216
+ test('no reaching gatherers throws', () => {
217
+ const e = entity({
218
+ gatherer_lanes: [gathererLane(0, 200, 500, 100)],
219
+ generator: energyStats(10000, 100),
220
+ energy: 10000,
221
+ })
222
+
223
+ expect(() => planParallelGather(e, {quantity: 10}, 2000, NOW)).toThrow(
224
+ 'no gatherer reaches this stratum'
225
+ )
226
+ })
227
+
228
+ test('two identical gatherers: per-lane quantity halved, durations equal and within 1s', () => {
229
+ const YIELD = 200
230
+ const DEPTH = 5000
231
+ const DRAIN = 500
232
+ const QUANTITY = 60
233
+ const STRATUM = 0
234
+
235
+ const eSingle = entity({
236
+ gatherer_lanes: [gathererLane(0, YIELD, DRAIN, DEPTH)],
237
+ generator: energyStats(10000, 100),
238
+ energy: 10000,
239
+ })
240
+ const eDouble = entity({
241
+ gatherer_lanes: [
242
+ gathererLane(0, YIELD, DRAIN, DEPTH),
243
+ gathererLane(1, YIELD, DRAIN, DEPTH),
244
+ ],
245
+ generator: energyStats(10000, 100),
246
+ energy: 10000,
247
+ })
248
+
249
+ const planSingle = planParallelGather(eSingle, {quantity: QUANTITY}, STRATUM, NOW)
250
+ const planDouble = planParallelGather(eDouble, {quantity: QUANTITY}, STRATUM, NOW)
251
+
252
+ expect(planSingle).toHaveLength(1)
253
+ expect(planDouble).toHaveLength(2)
254
+
255
+ const singleQ = planSingle[0].quantity
256
+ const doubleQ1 = planDouble[0].quantity
257
+ const doubleQ2 = planDouble[1].quantity
258
+ expect(doubleQ1 + doubleQ2).toBe(QUANTITY)
259
+
260
+ const ITEM_MASS = 228
261
+ const RICHNESS = 1000
262
+ const g: GathererStats = {
263
+ yield: UInt16.from(YIELD),
264
+ drain: UInt32.from(DRAIN),
265
+ depth: UInt16.from(DEPTH),
266
+ }
267
+ const durSingle = Number(calc_gather_duration(g, ITEM_MASS, singleQ, STRATUM, RICHNESS))
268
+ const durDouble1 = Number(calc_gather_duration(g, ITEM_MASS, doubleQ1, STRATUM, RICHNESS))
269
+ const durDouble2 = Number(calc_gather_duration(g, ITEM_MASS, doubleQ2, STRATUM, RICHNESS))
270
+
271
+ expect(durDouble1).toBeCloseTo(durSingle / 2, 0)
272
+ expect(durDouble2).toBeCloseTo(durSingle / 2, 0)
273
+ expect(Math.abs(durDouble1 - durDouble2)).toBeLessThan(1)
274
+ })
275
+ })
276
+
277
+ describe('planParallelTransfer', () => {
278
+ test('two loader lanes: quantities proportional to thrust, sums to target', () => {
279
+ const THRUST1 = 100
280
+ const THRUST2 = 200
281
+ const QUANTITY = 90
282
+
283
+ const e = entity({
284
+ loader_lanes: [loaderLane(0, 500, THRUST1), loaderLane(1, 500, THRUST2)],
285
+ })
286
+
287
+ const plan = planParallelTransfer(e, {quantity: QUANTITY})
288
+
289
+ expect(plan).toHaveLength(2)
290
+ expect(plan.reduce((s, p) => s + p.quantity, 0)).toBe(QUANTITY)
291
+
292
+ const q1 = plan.find((p) => p.slot === 0)!.quantity
293
+ const q2 = plan.find((p) => p.slot === 1)!.quantity
294
+ expect(q2 / q1).toBeCloseTo(THRUST2 / THRUST1, 0)
295
+ })
296
+
297
+ test('no loader lanes: returns empty plan', () => {
298
+ const plan = planParallelTransfer(entity({loader_lanes: []}), {quantity: 10})
299
+ expect(plan).toHaveLength(0)
300
+ })
301
+
302
+ test('thrust=0 loader lane (no-loader/mobility case): returns empty plan', () => {
303
+ const plan = planParallelTransfer(entity({loader_lanes: [loaderLane(0, 500, 0)]}), {
304
+ quantity: 10,
305
+ })
306
+ expect(plan).toHaveLength(0)
307
+ })
308
+
309
+ test("'max' target: each loader lane gets >= 1 unit", () => {
310
+ const e = entity({
311
+ loader_lanes: [loaderLane(0, 500, 100), loaderLane(1, 500, 200)],
312
+ })
313
+ const plan = planParallelTransfer(e, 'max')
314
+ expect(plan.length).toBeGreaterThan(0)
315
+ for (const entry of plan) {
316
+ expect(entry.quantity).toBeGreaterThanOrEqual(1)
317
+ }
318
+ })
319
+ })
@@ -287,7 +287,10 @@ function resolveEntity(
287
287
  let moduleSlots: ResolvedModuleSlot[] | undefined
288
288
 
289
289
  if (stats !== undefined) {
290
- const decoded = decodeCraftedItemStats(id, toBigStats(stats))
290
+ const bigStats = toBigStats(stats)
291
+ const decoded = decodeCraftedItemStats(id, bigStats)
292
+ if (decoded.strength === undefined) decoded.strength = decodeStat(bigStats, 0)
293
+ if (decoded.hardness === undefined) decoded.hardness = decodeStat(bigStats, 2)
291
294
  const hullCaps = hullCapsForEntity(id, decoded)
292
295
  attributes = [
293
296
  {
@@ -36,7 +36,7 @@ export function taskCargoEffect(task: Task): CargoEffect {
36
36
  }
37
37
  }
38
38
 
39
- function cargoKey(item: CargoItem): string {
39
+ export function cargoKey(item: CargoItem): string {
40
40
  const base = `${item.item_id.toNumber()}:${item.stats.toString()}`
41
41
  const modules = item.modules ?? []
42
42
  const entityId = item.entity_id?.toString()
@@ -0,0 +1,348 @@
1
+ import {describe, expect, test} from 'bun:test'
2
+ import {ServerContract, TaskType, TaskCancelable} from '../index-module'
3
+ import {cancelEligibility, CancelBlockReason} from './cancel'
4
+
5
+ const T0 = '2026-06-19T00:00:00'
6
+
7
+ function task(over: Partial<{type: number; duration: number; cancelable: number; group: number}>) {
8
+ return ServerContract.Types.task.from({
9
+ type: over.type ?? TaskType.TRAVEL,
10
+ duration: over.duration ?? 100,
11
+ cancelable: over.cancelable ?? TaskCancelable.BEFORE_START,
12
+ cargo: [],
13
+ ...(over.group ? {entitygroup: over.group} : {}),
14
+ })
15
+ }
16
+
17
+ function entity(tasks: ReturnType<typeof task>[], startedISO = T0) {
18
+ return ServerContract.Types.entity_info.from({
19
+ type: 'ship',
20
+ id: 1,
21
+ owner: 'player.gm',
22
+ entity_name: 'Ship 1',
23
+ coordinates: {x: 0, y: 0, z: 0},
24
+ item_id: 1,
25
+ cargomass: 0,
26
+ cargo: [],
27
+ modules: [],
28
+ lanes: [{lane_key: 0, schedule: {started: startedISO, tasks}}],
29
+ gatherer_lanes: [],
30
+ crafter_lanes: [],
31
+ loader_lanes: [],
32
+ holds: [],
33
+ })
34
+ }
35
+
36
+ describe('cancelEligibility — local gates', () => {
37
+ const now = new Date('2026-06-19T00:00:10.000Z') // 10s in: task 0 running, task 1 upcoming
38
+
39
+ test('cancelling the last upcoming task: ok, count 1', () => {
40
+ const e = entity([task({}), task({})])
41
+ const plan = cancelEligibility(e, 0, 1, {now})
42
+ expect(plan.ok).toBe(true)
43
+ expect(plan.range.count).toBe(1)
44
+ expect(plan.range.taskIndices).toEqual([1])
45
+ })
46
+
47
+ test('NEVER task is blocked', () => {
48
+ const e = entity([
49
+ task({}),
50
+ task({cancelable: TaskCancelable.NEVER, type: TaskType.GATHER}),
51
+ ])
52
+ expect(cancelEligibility(e, 0, 1, {now}).blockedReason).toBe(CancelBlockReason.TASK_NEVER)
53
+ })
54
+
55
+ test('BEFORE_START task that is running is blocked', () => {
56
+ const e = entity([task({cancelable: TaskCancelable.BEFORE_START, duration: 100})])
57
+ // task 0 is running at now=10s
58
+ expect(cancelEligibility(e, 0, 0, {now}).blockedReason).toBe(
59
+ CancelBlockReason.BEFORE_START_RUNNING
60
+ )
61
+ })
62
+
63
+ test('done task is blocked', () => {
64
+ const e = entity([task({duration: 5})]) // completes at 5s, now=10s
65
+ expect(cancelEligibility(e, 0, 0, {now}).blockedReason).toBe(CancelBlockReason.DONE)
66
+ })
67
+
68
+ test('unknown lane: count 0, ok false', () => {
69
+ const e = entity([task({})])
70
+ const plan = cancelEligibility(e, 9, 0, {now})
71
+ expect(plan.ok).toBe(false)
72
+ expect(plan.range.count).toBe(0)
73
+ })
74
+
75
+ test('multi-task range: 4 tasks, fromIndex 1 yields count 3', () => {
76
+ const e = entity([
77
+ task({duration: 100}),
78
+ task({cancelable: TaskCancelable.ALWAYS, duration: 100}),
79
+ task({cancelable: TaskCancelable.ALWAYS, duration: 100}),
80
+ task({cancelable: TaskCancelable.ALWAYS, duration: 100}),
81
+ ])
82
+ const plan = cancelEligibility(e, 0, 1, {now})
83
+ expect(plan.ok).toBe(true)
84
+ expect(plan.range.count).toBe(3)
85
+ expect(plan.range.taskIndices).toEqual([1, 2, 3])
86
+ })
87
+ })
88
+
89
+ describe('cancelEligibility — linked tasks', () => {
90
+ const now = new Date('2026-06-19T00:00:10.000Z')
91
+ test('range containing a linked (entitygroup) task is blocked', () => {
92
+ const e = entity([task({}), task({group: 42}), task({})])
93
+ expect(cancelEligibility(e, 0, 0, {now}).blockedReason).toBe(
94
+ CancelBlockReason.CONTAINS_LINKED_TASK
95
+ )
96
+ })
97
+ test('task after the linked one cancels normally', () => {
98
+ const e = entity([task({}), task({group: 42}), task({})])
99
+ expect(cancelEligibility(e, 0, 2, {now}).ok).toBe(true)
100
+ })
101
+ })
102
+
103
+ const HOLD_PULL = 1
104
+ function loadTask(giverType: string, giverId: number, holdId: number, qty: number) {
105
+ return ServerContract.Types.task.from({
106
+ type: TaskType.LOAD,
107
+ duration: 100,
108
+ cancelable: TaskCancelable.ALWAYS,
109
+ cargo: [{item_id: 7, stats: 0, modules: [], quantity: qty}],
110
+ entitytarget: {entity_type: giverType, entity_id: giverId},
111
+ hold: holdId,
112
+ })
113
+ }
114
+
115
+ describe('cancelEligibility — effects', () => {
116
+ const now = new Date('2026-06-19T00:00:10.000Z')
117
+
118
+ test('abandonsRunning true when the front of range is a running ALWAYS task', () => {
119
+ const e = entity([
120
+ task({cancelable: TaskCancelable.ALWAYS, type: TaskType.RECHARGE, duration: 100}),
121
+ ])
122
+ expect(cancelEligibility(e, 0, 0, {now}).effects.abandonsRunning).toBe(true)
123
+ })
124
+
125
+ test('buildplot cancel reports keepsPlotDeposits', () => {
126
+ const e = entity([
127
+ ServerContract.Types.task.from({
128
+ type: TaskType.BUILDPLOT,
129
+ duration: 100,
130
+ cancelable: TaskCancelable.ALWAYS,
131
+ cargo: [],
132
+ entitytarget: {entity_type: 'plot', entity_id: 55},
133
+ }),
134
+ ])
135
+ const plan = cancelEligibility(e, 0, 0, {now})
136
+ expect(plan.effects.keepsPlotDeposits?.plot.entity_id.toNumber()).toBe(55)
137
+ })
138
+
139
+ test('PULL load cancel refunds cargo to the giver', () => {
140
+ const lt = loadTask('warehouse', 6, 1, 4)
141
+ const upcoming = new Date('2026-06-18T23:59:50.000Z')
142
+ const e = ServerContract.Types.entity_info.from({
143
+ type: 'ship',
144
+ id: 1,
145
+ owner: 'player.gm',
146
+ entity_name: 'Ship 1',
147
+ coordinates: {x: 0, y: 0, z: 0},
148
+ item_id: 1,
149
+ cargomass: 0,
150
+ cargo: [],
151
+ modules: [],
152
+ lanes: [{lane_key: 0, schedule: {started: T0, tasks: [lt]}}],
153
+ gatherer_lanes: [],
154
+ crafter_lanes: [],
155
+ loader_lanes: [],
156
+ holds: [
157
+ {
158
+ id: 1,
159
+ kind: HOLD_PULL,
160
+ counterpart: {entity_type: 'warehouse', entity_id: 6},
161
+ until: T0,
162
+ incoming_mass: 0,
163
+ },
164
+ ],
165
+ })
166
+ const plan = cancelEligibility(e, 0, 0, {now: upcoming})
167
+ expect(plan.effects.refunds[0]?.giver.entity_id.toNumber()).toBe(6)
168
+ expect(plan.effects.refunds[0]?.cargo[0].quantity.toNumber()).toBe(4)
169
+ })
170
+
171
+ test('PULL load cancel populates releasedHolds with kind and counterpart', () => {
172
+ const lt = loadTask('warehouse', 6, 1, 4)
173
+ const upcoming = new Date('2026-06-18T23:59:50.000Z')
174
+ const e = ServerContract.Types.entity_info.from({
175
+ type: 'ship',
176
+ id: 1,
177
+ owner: 'player.gm',
178
+ entity_name: 'Ship 1',
179
+ coordinates: {x: 0, y: 0, z: 0},
180
+ item_id: 1,
181
+ cargomass: 0,
182
+ cargo: [],
183
+ modules: [],
184
+ lanes: [{lane_key: 0, schedule: {started: T0, tasks: [lt]}}],
185
+ gatherer_lanes: [],
186
+ crafter_lanes: [],
187
+ loader_lanes: [],
188
+ holds: [
189
+ {
190
+ id: 1,
191
+ kind: HOLD_PULL,
192
+ counterpart: {entity_type: 'warehouse', entity_id: 6},
193
+ until: T0,
194
+ incoming_mass: 0,
195
+ },
196
+ ],
197
+ })
198
+ const plan = cancelEligibility(e, 0, 0, {now: upcoming})
199
+ expect(plan.effects.releasedHolds[0]?.kind).toBe(1)
200
+ expect(plan.effects.releasedHolds[0]?.counterpart.entity_id.toNumber()).toBe(6)
201
+ })
202
+
203
+ test('unresolvable hold emits no releasedHolds entry', () => {
204
+ const lt = loadTask('warehouse', 6, 99, 4)
205
+ const upcoming = new Date('2026-06-18T23:59:50.000Z')
206
+ const e = ServerContract.Types.entity_info.from({
207
+ type: 'ship',
208
+ id: 1,
209
+ owner: 'player.gm',
210
+ entity_name: 'Ship 1',
211
+ coordinates: {x: 0, y: 0, z: 0},
212
+ item_id: 1,
213
+ cargomass: 0,
214
+ cargo: [],
215
+ modules: [],
216
+ lanes: [{lane_key: 0, schedule: {started: T0, tasks: [lt]}}],
217
+ gatherer_lanes: [],
218
+ crafter_lanes: [],
219
+ loader_lanes: [],
220
+ holds: [],
221
+ })
222
+ const plan = cancelEligibility(e, 0, 0, {now: upcoming})
223
+ expect(plan.effects.releasedHolds).toHaveLength(0)
224
+ })
225
+ })
226
+
227
+ describe('cancelEligibility — feasibility', () => {
228
+ const upcoming = new Date('2026-06-18T23:59:50.000Z')
229
+
230
+ test('WOULD_STRAND when cancelling a producer a later consumer needs', () => {
231
+ const producer = ServerContract.Types.task.from({
232
+ type: TaskType.LOAD,
233
+ duration: 50,
234
+ cancelable: TaskCancelable.ALWAYS,
235
+ cargo: [{item_id: 7, stats: 0, modules: [], quantity: 2}],
236
+ })
237
+ const consumer = ServerContract.Types.task.from({
238
+ type: TaskType.CRAFT,
239
+ duration: 50,
240
+ cancelable: TaskCancelable.ALWAYS,
241
+ cargo: [
242
+ {item_id: 7, stats: 0, modules: [], quantity: 2},
243
+ {item_id: 9, stats: 0, modules: [], quantity: 1},
244
+ ],
245
+ })
246
+ const e = ServerContract.Types.entity_info.from({
247
+ type: 'ship',
248
+ id: 1,
249
+ owner: 'player.gm',
250
+ entity_name: 'S',
251
+ coordinates: {x: 0, y: 0, z: 0},
252
+ item_id: 1,
253
+ cargomass: 0,
254
+ cargo: [],
255
+ modules: [],
256
+ lanes: [
257
+ {
258
+ lane_key: 1,
259
+ schedule: {started: '2026-06-19T00:00:00', tasks: [producer]},
260
+ },
261
+ {
262
+ lane_key: 2,
263
+ schedule: {started: '2026-06-19T00:00:00', tasks: [consumer]},
264
+ },
265
+ ],
266
+ gatherer_lanes: [],
267
+ crafter_lanes: [],
268
+ loader_lanes: [],
269
+ holds: [],
270
+ })
271
+ expect(cancelEligibility(e, 1, 0, {now: upcoming}).blockedReason).toBe(
272
+ CancelBlockReason.WOULD_STRAND
273
+ )
274
+ })
275
+
276
+ test('benign cancel on independent lane does NOT strand', () => {
277
+ const producer = ServerContract.Types.task.from({
278
+ type: TaskType.LOAD,
279
+ duration: 50,
280
+ cancelable: TaskCancelable.ALWAYS,
281
+ cargo: [{item_id: 7, stats: 0, modules: [], quantity: 2}],
282
+ })
283
+ const independent = ServerContract.Types.task.from({
284
+ type: TaskType.TRAVEL,
285
+ duration: 50,
286
+ cancelable: TaskCancelable.ALWAYS,
287
+ cargo: [],
288
+ })
289
+ const e = ServerContract.Types.entity_info.from({
290
+ type: 'ship',
291
+ id: 1,
292
+ owner: 'player.gm',
293
+ entity_name: 'S',
294
+ coordinates: {x: 0, y: 0, z: 0},
295
+ item_id: 1,
296
+ cargomass: 0,
297
+ cargo: [],
298
+ modules: [],
299
+ lanes: [
300
+ {lane_key: 1, schedule: {started: '2026-06-19T00:00:00', tasks: [producer]}},
301
+ {lane_key: 2, schedule: {started: '2026-06-19T00:00:00', tasks: [independent]}},
302
+ ],
303
+ gatherer_lanes: [],
304
+ crafter_lanes: [],
305
+ loader_lanes: [],
306
+ holds: [],
307
+ })
308
+ expect(cancelEligibility(e, 2, 0, {now: upcoming}).ok).toBe(true)
309
+ })
310
+
311
+ test('WOULD_STRAND when cancelling producer of a MODULAR cargo the consumer needs', () => {
312
+ const moduledCargo = {item_id: 7, stats: 0, modules: [{type: 3}], quantity: 2}
313
+ const producer = ServerContract.Types.task.from({
314
+ type: TaskType.LOAD,
315
+ duration: 50,
316
+ cancelable: TaskCancelable.ALWAYS,
317
+ cargo: [moduledCargo],
318
+ })
319
+ const consumer = ServerContract.Types.task.from({
320
+ type: TaskType.CRAFT,
321
+ duration: 50,
322
+ cancelable: TaskCancelable.ALWAYS,
323
+ cargo: [moduledCargo, {item_id: 9, stats: 0, modules: [], quantity: 1}],
324
+ })
325
+ const e = ServerContract.Types.entity_info.from({
326
+ type: 'ship',
327
+ id: 1,
328
+ owner: 'player.gm',
329
+ entity_name: 'S',
330
+ coordinates: {x: 0, y: 0, z: 0},
331
+ item_id: 1,
332
+ cargomass: 0,
333
+ cargo: [],
334
+ modules: [],
335
+ lanes: [
336
+ {lane_key: 1, schedule: {started: '2026-06-19T00:00:00', tasks: [producer]}},
337
+ {lane_key: 2, schedule: {started: '2026-06-19T00:00:00', tasks: [consumer]}},
338
+ ],
339
+ gatherer_lanes: [],
340
+ crafter_lanes: [],
341
+ loader_lanes: [],
342
+ holds: [],
343
+ })
344
+ expect(cancelEligibility(e, 1, 0, {now: upcoming}).blockedReason).toBe(
345
+ CancelBlockReason.WOULD_STRAND
346
+ )
347
+ })
348
+ })