@shipload/sdk 1.0.0-next.4 → 1.0.0-next.41

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 (134) hide show
  1. package/lib/scan.d.ts +34 -0
  2. package/lib/scan.js +136 -0
  3. package/lib/scan.js.map +1 -0
  4. package/lib/scan.m.js +129 -0
  5. package/lib/scan.m.js.map +1 -0
  6. package/lib/shipload.d.ts +2473 -973
  7. package/lib/shipload.js +11529 -5211
  8. package/lib/shipload.js.map +1 -1
  9. package/lib/shipload.m.js +11338 -5162
  10. package/lib/shipload.m.js.map +1 -1
  11. package/lib/testing.d.ts +970 -0
  12. package/lib/testing.js +4013 -0
  13. package/lib/testing.js.map +1 -0
  14. package/lib/testing.m.js +4007 -0
  15. package/lib/testing.m.js.map +1 -0
  16. package/package.json +20 -2
  17. package/src/capabilities/craftable.ts +51 -0
  18. package/src/capabilities/crafting.test.ts +7 -0
  19. package/src/capabilities/crafting.ts +5 -6
  20. package/src/capabilities/gathering.test.ts +16 -0
  21. package/src/capabilities/gathering.ts +35 -18
  22. package/src/capabilities/index.ts +0 -1
  23. package/src/capabilities/modules.ts +9 -0
  24. package/src/capabilities/storage.ts +16 -1
  25. package/src/contracts/platform.ts +231 -3
  26. package/src/contracts/server.ts +1021 -481
  27. package/src/coordinates/address.ts +88 -0
  28. package/src/coordinates/constants.test.ts +15 -0
  29. package/src/coordinates/constants.ts +23 -0
  30. package/src/coordinates/index.ts +15 -0
  31. package/src/coordinates/memo.test.ts +47 -0
  32. package/src/coordinates/memo.ts +20 -0
  33. package/src/coordinates/permutation.ts +77 -0
  34. package/src/coordinates/regions.ts +48 -0
  35. package/src/coordinates/sectors.ts +115 -0
  36. package/src/data/capabilities.ts +12 -5
  37. package/src/data/capability-formulas.ts +14 -7
  38. package/src/data/catalog.ts +0 -5
  39. package/src/data/colors.ts +14 -47
  40. package/src/data/entities.json +76 -10
  41. package/src/data/item-ids.ts +18 -12
  42. package/src/data/items.json +321 -38
  43. package/src/data/kind-registry.json +109 -0
  44. package/src/data/kind-registry.ts +165 -0
  45. package/src/data/metadata.ts +119 -33
  46. package/src/data/recipes-runtime.ts +3 -23
  47. package/src/data/recipes.json +238 -117
  48. package/src/derivation/build-methods.ts +45 -0
  49. package/src/derivation/capabilities.test.ts +151 -0
  50. package/src/derivation/capabilities.ts +512 -0
  51. package/src/derivation/capability-mappings.ts +9 -12
  52. package/src/derivation/crafting.ts +23 -24
  53. package/src/derivation/index.ts +25 -2
  54. package/src/derivation/recipe-usage.test.ts +78 -0
  55. package/src/derivation/recipe-usage.ts +141 -0
  56. package/src/derivation/reserve-regen.ts +34 -0
  57. package/src/derivation/resources.ts +125 -38
  58. package/src/derivation/rollups.test.ts +55 -0
  59. package/src/derivation/rollups.ts +56 -0
  60. package/src/derivation/stars.test.ts +51 -0
  61. package/src/derivation/stars.ts +15 -0
  62. package/src/derivation/stats.ts +6 -6
  63. package/src/derivation/stratum.ts +17 -20
  64. package/src/derivation/tiers.ts +40 -7
  65. package/src/derivation/wormhole.ts +136 -0
  66. package/src/entities/entity.ts +98 -0
  67. package/src/entities/gamestate.ts +3 -28
  68. package/src/entities/makers.ts +124 -134
  69. package/src/entities/slot-multiplier.ts +43 -0
  70. package/src/errors.ts +12 -16
  71. package/src/format.ts +26 -4
  72. package/src/index-module.ts +267 -47
  73. package/src/managers/actions.ts +528 -95
  74. package/src/managers/base.ts +6 -2
  75. package/src/managers/construction-types.ts +80 -0
  76. package/src/managers/construction.ts +412 -0
  77. package/src/managers/context.ts +20 -1
  78. package/src/managers/coordinates.ts +14 -0
  79. package/src/managers/entities.ts +18 -66
  80. package/src/managers/epochs.ts +40 -0
  81. package/src/managers/index.ts +17 -1
  82. package/src/managers/locations.ts +25 -29
  83. package/src/managers/nft.test.ts +14 -0
  84. package/src/managers/nft.ts +70 -0
  85. package/src/managers/plot.ts +122 -0
  86. package/src/nft/atomicassets.abi.json +1342 -0
  87. package/src/nft/atomicassets.ts +237 -0
  88. package/src/nft/atomicdata.ts +130 -0
  89. package/src/nft/buildImmutableData.ts +338 -0
  90. package/src/nft/description.ts +98 -24
  91. package/src/nft/index.ts +3 -0
  92. package/src/planner/index.ts +127 -0
  93. package/src/planner/planner.test.ts +319 -0
  94. package/src/resolution/describe-module.ts +18 -13
  95. package/src/resolution/display-name.ts +38 -10
  96. package/src/resolution/resolve-item.test.ts +37 -0
  97. package/src/resolution/resolve-item.ts +55 -24
  98. package/src/scan/index.ts +180 -0
  99. package/src/scan/scan-wasm.base64.ts +2 -0
  100. package/src/scheduling/accessor.ts +68 -22
  101. package/src/scheduling/availability.ts +108 -0
  102. package/src/scheduling/cancel.test.ts +348 -0
  103. package/src/scheduling/cancel.ts +209 -0
  104. package/src/scheduling/energy.ts +47 -0
  105. package/src/scheduling/idle-resolve.ts +45 -0
  106. package/src/scheduling/lane-core.ts +128 -0
  107. package/src/scheduling/lanes.test.ts +249 -0
  108. package/src/scheduling/lanes.ts +198 -0
  109. package/src/scheduling/projection.ts +209 -105
  110. package/src/scheduling/schedule.ts +241 -104
  111. package/src/scheduling/task-cargo.ts +46 -0
  112. package/src/shipload.ts +21 -1
  113. package/src/subscriptions/manager.ts +229 -142
  114. package/src/subscriptions/mappers.ts +5 -8
  115. package/src/subscriptions/types.ts +11 -3
  116. package/src/testing/catalog-hash.ts +19 -0
  117. package/src/testing/index.ts +2 -0
  118. package/src/testing/projection-parity.ts +167 -0
  119. package/src/travel/reach.ts +23 -0
  120. package/src/travel/route-planner.ts +196 -0
  121. package/src/travel/travel.ts +200 -112
  122. package/src/types/capabilities.ts +29 -6
  123. package/src/types/entity.ts +3 -3
  124. package/src/types/index.ts +0 -1
  125. package/src/types.ts +28 -13
  126. package/src/utils/cargo.ts +27 -0
  127. package/src/utils/display-name.ts +70 -0
  128. package/src/utils/system.ts +36 -24
  129. package/src/capabilities/loading.ts +0 -8
  130. package/src/entities/container.ts +0 -108
  131. package/src/entities/ship-deploy.ts +0 -259
  132. package/src/entities/ship.ts +0 -204
  133. package/src/entities/warehouse.ts +0 -119
  134. package/src/types/entity-traits.ts +0 -69
@@ -1,82 +1,128 @@
1
1
  import type {ServerContract} from '../contracts'
2
2
  import type {TaskType} from '../types'
3
- import type {ScheduleData} from './schedule'
4
- import * as schedule from './schedule'
3
+ import * as core from './lane-core'
4
+ import {
5
+ activeTasks,
6
+ getLane,
7
+ getLanes,
8
+ hasSchedule,
9
+ isIdle,
10
+ LANE_MOBILITY,
11
+ type LaneView,
12
+ type ScheduleData,
13
+ } from './schedule'
5
14
 
6
15
  type Task = ServerContract.Types.task
7
16
 
8
17
  export class ScheduleAccessor {
9
- constructor(private entity: ScheduleData) {}
18
+ private _laneResolved = false
19
+ private _lane: LaneView | undefined
20
+
21
+ constructor(
22
+ private entity: ScheduleData,
23
+ private laneKey: number = LANE_MOBILITY
24
+ ) {}
25
+
26
+ private get lane(): LaneView | undefined {
27
+ if (!this._laneResolved) {
28
+ this._lane = getLane(this.entity, this.laneKey)
29
+ this._laneResolved = true
30
+ }
31
+ return this._lane
32
+ }
33
+
34
+ forLane(laneKey: number): ScheduleAccessor {
35
+ return new ScheduleAccessor(this.entity, laneKey)
36
+ }
37
+
38
+ get lanes(): LaneView[] {
39
+ return getLanes(this.entity)
40
+ }
10
41
 
11
42
  get hasSchedule(): boolean {
12
- return schedule.hasSchedule(this.entity)
43
+ return hasSchedule(this.entity)
13
44
  }
14
45
 
15
46
  get isIdle(): boolean {
16
- return schedule.isIdle(this.entity)
47
+ return isIdle(this.entity)
17
48
  }
18
49
 
19
50
  get tasks(): Task[] {
20
- return schedule.getTasks(this.entity)
51
+ return this.lane?.schedule.tasks ?? []
52
+ }
53
+
54
+ activeTasks(now: Date): Task[] {
55
+ return activeTasks(this.entity, now)
21
56
  }
22
57
 
23
58
  duration(): number {
24
- return schedule.scheduleDuration(this.entity)
59
+ return this.lane ? core.laneDuration(this.lane.schedule) : 0
25
60
  }
26
61
 
27
62
  elapsed(now: Date): number {
28
- return schedule.scheduleElapsed(this.entity, now)
63
+ return this.lane ? core.laneElapsed(this.lane.schedule, now) : 0
29
64
  }
30
65
 
31
66
  remaining(now: Date): number {
32
- return schedule.scheduleRemaining(this.entity, now)
67
+ return this.lane ? core.laneRemaining(this.lane.schedule, now) : 0
68
+ }
69
+
70
+ startsIn(now: Date): number {
71
+ return this.lane ? core.laneStartsIn(this.lane.schedule, now) : 0
33
72
  }
34
73
 
35
74
  complete(now: Date): boolean {
36
- return schedule.scheduleComplete(this.entity, now)
75
+ return this.lane ? core.laneComplete(this.lane.schedule, now) : false
37
76
  }
38
77
 
39
78
  currentTaskIndex(now: Date): number {
40
- return schedule.currentTaskIndex(this.entity, now)
79
+ return this.lane ? core.currentTaskIndexForLane(this.lane.schedule, now) : -1
41
80
  }
42
81
 
43
82
  currentTask(now: Date): Task | undefined {
44
- return schedule.currentTask(this.entity, now)
83
+ return this.lane ? core.currentTask(this.lane.schedule, now) : undefined
45
84
  }
46
85
 
47
86
  currentTaskType(now: Date): TaskType | undefined {
48
- return schedule.currentTaskType(this.entity, now)
87
+ return this.lane ? core.currentTaskType(this.lane.schedule, now) : undefined
49
88
  }
50
89
 
51
90
  taskStartTime(index: number): number {
52
- return schedule.getTaskStartTime(this.entity, index)
91
+ return this.lane ? core.laneTaskStartTime(this.lane.schedule, index) : 0
53
92
  }
54
93
 
55
94
  taskElapsed(index: number, now: Date): number {
56
- return schedule.getTaskElapsed(this.entity, index, now)
95
+ return this.lane ? core.laneTaskElapsed(this.lane.schedule, index, now) : 0
57
96
  }
58
97
 
59
98
  taskRemaining(index: number, now: Date): number {
60
- return schedule.getTaskRemaining(this.entity, index, now)
99
+ return this.lane ? core.laneTaskRemaining(this.lane.schedule, index, now) : 0
61
100
  }
62
101
 
63
102
  taskComplete(index: number, now: Date): boolean {
64
- return schedule.isTaskComplete(this.entity, index, now)
103
+ return this.lane ? core.laneTaskComplete(this.lane.schedule, index, now) : false
65
104
  }
66
105
 
67
106
  taskInProgress(index: number, now: Date): boolean {
68
- return schedule.isTaskInProgress(this.entity, index, now)
107
+ return this.lane ? core.laneTaskInProgress(this.lane.schedule, index, now) : false
69
108
  }
70
109
 
71
110
  currentTaskProgress(now: Date): number {
72
- return schedule.currentTaskProgress(this.entity, now)
111
+ return this.lane ? core.currentTaskProgress(this.lane.schedule, now) : 0
112
+ }
113
+
114
+ currentTaskProgressFloat(now: Date): number {
115
+ return this.lane ? core.currentTaskProgressFloatForLane(this.lane.schedule, now) : 0
73
116
  }
74
117
 
75
118
  progress(now: Date): number {
76
- return schedule.scheduleProgress(this.entity, now)
119
+ return this.lane ? core.laneProgress(this.lane.schedule, now) : 0
77
120
  }
78
121
  }
79
122
 
80
- export function createScheduleAccessor(entity: ScheduleData): ScheduleAccessor {
81
- return new ScheduleAccessor(entity)
123
+ export function createScheduleAccessor(
124
+ entity: ScheduleData,
125
+ laneKey: number = LANE_MOBILITY
126
+ ): ScheduleAccessor {
127
+ return new ScheduleAccessor(entity, laneKey)
82
128
  }
@@ -0,0 +1,108 @@
1
+ import type {ServerContract} from '../contracts'
2
+ import {TaskType} from '../types'
3
+ import * as schedule from './schedule'
4
+
5
+ type Task = ServerContract.Types.task
6
+ type CargoItem = ServerContract.Types.cargo_item
7
+
8
+ export interface CargoEffect {
9
+ added: CargoItem[]
10
+ removed: CargoItem[]
11
+ }
12
+
13
+ export interface AvailabilityInput extends schedule.ScheduleData {
14
+ cargo: CargoItem[]
15
+ }
16
+
17
+ export function taskCargoEffect(task: Task): CargoEffect {
18
+ switch (task.type.toNumber()) {
19
+ case TaskType.LOAD:
20
+ case TaskType.UNWRAP:
21
+ case TaskType.UNDEPLOY:
22
+ return {added: task.cargo, removed: []}
23
+ case TaskType.UNLOAD:
24
+ return {added: [], removed: task.cargo}
25
+ case TaskType.GATHER:
26
+ return task.entitytarget ? {added: [], removed: []} : {added: task.cargo, removed: []}
27
+ case TaskType.CRAFT:
28
+ if (task.cargo.length === 0) return {added: [], removed: []}
29
+ return {added: [task.cargo[task.cargo.length - 1]], removed: task.cargo.slice(0, -1)}
30
+ case TaskType.DEPLOY:
31
+ return task.cargo.length > 0
32
+ ? {added: [], removed: [task.cargo[0]]}
33
+ : {added: [], removed: []}
34
+ default:
35
+ return {added: [], removed: []}
36
+ }
37
+ }
38
+
39
+ export function cargoKey(item: CargoItem): string {
40
+ const base = `${item.item_id.toNumber()}:${item.stats.toString()}`
41
+ const modules = item.modules ?? []
42
+ const entityId = item.entity_id?.toString()
43
+ const normalizedEntityId = entityId && entityId !== '0' ? entityId : ''
44
+ if (modules.length === 0 && normalizedEntityId === '') return base
45
+ return `${base}:modules=${JSON.stringify(modules)}:entity=${normalizedEntityId}`
46
+ }
47
+
48
+ function cargoQuantity(item: CargoItem): bigint {
49
+ return BigInt(item.quantity.toString())
50
+ }
51
+
52
+ export function projectedCargoAvailableAt(
53
+ entity: AvailabilityInput,
54
+ at: Date
55
+ ): Map<string, bigint> {
56
+ const avail = new Map<string, bigint>()
57
+
58
+ for (const item of entity.cargo) {
59
+ const key = cargoKey(item)
60
+ avail.set(key, (avail.get(key) ?? 0n) + cargoQuantity(item))
61
+ }
62
+
63
+ // Every scheduled task reserves inputs against the unsettled cargo base, even already-elapsed ones.
64
+ const tasks = schedule.orderedTasks(entity)
65
+
66
+ for (const ordered of tasks) {
67
+ if (ordered.completesAt.getTime() >= at.getTime()) continue
68
+
69
+ for (const item of taskCargoEffect(ordered.task).added) {
70
+ const key = cargoKey(item)
71
+ avail.set(key, (avail.get(key) ?? 0n) + cargoQuantity(item))
72
+ }
73
+ }
74
+
75
+ for (const ordered of tasks) {
76
+ for (const item of taskCargoEffect(ordered.task).removed) {
77
+ const key = cargoKey(item)
78
+ const current = avail.get(key) ?? 0n
79
+ const quantity = cargoQuantity(item)
80
+ avail.set(key, current > quantity ? current - quantity : 0n)
81
+ }
82
+ }
83
+
84
+ return avail
85
+ }
86
+
87
+ // Latest completion among scheduled tasks producing any of the given inputs (a craft starts no earlier).
88
+ export function cargoReadyAt(entity: AvailabilityInput, inputItemIds: readonly number[]): Date {
89
+ let readyMs = 0
90
+ for (const ordered of schedule.orderedTasks(entity)) {
91
+ for (const item of taskCargoEffect(ordered.task).added) {
92
+ if (inputItemIds.includes(item.item_id.toNumber())) {
93
+ readyMs = Math.max(readyMs, ordered.completesAt.getTime())
94
+ break
95
+ }
96
+ }
97
+ }
98
+ return new Date(readyMs)
99
+ }
100
+
101
+ export function availableForItem(avail: Map<string, bigint>, itemId: number): bigint {
102
+ const prefix = `${itemId}:`
103
+ let total = 0n
104
+ for (const [key, quantity] of avail) {
105
+ if (key.startsWith(prefix)) total += quantity
106
+ }
107
+ return total
108
+ }
@@ -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
+ })