@shipload/sdk 1.0.0-next.33 → 1.0.0-next.35

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.
@@ -0,0 +1,327 @@
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
+ holds: [],
30
+ })
31
+ }
32
+
33
+ describe('cancelEligibility — local gates', () => {
34
+ const now = new Date('2026-06-19T00:00:10.000Z') // 10s in: task 0 running, task 1 upcoming
35
+
36
+ test('cancelling the last upcoming task: ok, count 1', () => {
37
+ const e = entity([task({}), task({})])
38
+ const plan = cancelEligibility(e, 0, 1, {now})
39
+ expect(plan.ok).toBe(true)
40
+ expect(plan.range.count).toBe(1)
41
+ expect(plan.range.taskIndices).toEqual([1])
42
+ })
43
+
44
+ test('NEVER task is blocked', () => {
45
+ const e = entity([
46
+ task({}),
47
+ task({cancelable: TaskCancelable.NEVER, type: TaskType.GATHER}),
48
+ ])
49
+ expect(cancelEligibility(e, 0, 1, {now}).blockedReason).toBe(CancelBlockReason.TASK_NEVER)
50
+ })
51
+
52
+ test('BEFORE_START task that is running is blocked', () => {
53
+ const e = entity([task({cancelable: TaskCancelable.BEFORE_START, duration: 100})])
54
+ // task 0 is running at now=10s
55
+ expect(cancelEligibility(e, 0, 0, {now}).blockedReason).toBe(
56
+ CancelBlockReason.BEFORE_START_RUNNING
57
+ )
58
+ })
59
+
60
+ test('done task is blocked', () => {
61
+ const e = entity([task({duration: 5})]) // completes at 5s, now=10s
62
+ expect(cancelEligibility(e, 0, 0, {now}).blockedReason).toBe(CancelBlockReason.DONE)
63
+ })
64
+
65
+ test('unknown lane: count 0, ok false', () => {
66
+ const e = entity([task({})])
67
+ const plan = cancelEligibility(e, 9, 0, {now})
68
+ expect(plan.ok).toBe(false)
69
+ expect(plan.range.count).toBe(0)
70
+ })
71
+
72
+ test('multi-task range: 4 tasks, fromIndex 1 yields count 3', () => {
73
+ const e = entity([
74
+ task({duration: 100}),
75
+ task({cancelable: TaskCancelable.ALWAYS, duration: 100}),
76
+ task({cancelable: TaskCancelable.ALWAYS, duration: 100}),
77
+ task({cancelable: TaskCancelable.ALWAYS, duration: 100}),
78
+ ])
79
+ const plan = cancelEligibility(e, 0, 1, {now})
80
+ expect(plan.ok).toBe(true)
81
+ expect(plan.range.count).toBe(3)
82
+ expect(plan.range.taskIndices).toEqual([1, 2, 3])
83
+ })
84
+ })
85
+
86
+ describe('cancelEligibility — linked tasks', () => {
87
+ const now = new Date('2026-06-19T00:00:10.000Z')
88
+ test('range containing a linked (entitygroup) task is blocked', () => {
89
+ const e = entity([task({}), task({group: 42}), task({})])
90
+ expect(cancelEligibility(e, 0, 0, {now}).blockedReason).toBe(
91
+ CancelBlockReason.CONTAINS_LINKED_TASK
92
+ )
93
+ })
94
+ test('task after the linked one cancels normally', () => {
95
+ const e = entity([task({}), task({group: 42}), task({})])
96
+ expect(cancelEligibility(e, 0, 2, {now}).ok).toBe(true)
97
+ })
98
+ })
99
+
100
+ const HOLD_PULL = 1
101
+ function loadTask(giverType: string, giverId: number, holdId: number, qty: number) {
102
+ return ServerContract.Types.task.from({
103
+ type: TaskType.LOAD,
104
+ duration: 100,
105
+ cancelable: TaskCancelable.ALWAYS,
106
+ cargo: [{item_id: 7, stats: 0, modules: [], quantity: qty}],
107
+ entitytarget: {entity_type: giverType, entity_id: giverId},
108
+ hold: holdId,
109
+ })
110
+ }
111
+
112
+ describe('cancelEligibility — effects', () => {
113
+ const now = new Date('2026-06-19T00:00:10.000Z')
114
+
115
+ test('abandonsRunning true when the front of range is a running ALWAYS task', () => {
116
+ const e = entity([
117
+ task({cancelable: TaskCancelable.ALWAYS, type: TaskType.RECHARGE, duration: 100}),
118
+ ])
119
+ expect(cancelEligibility(e, 0, 0, {now}).effects.abandonsRunning).toBe(true)
120
+ })
121
+
122
+ test('buildplot cancel reports keepsPlotDeposits', () => {
123
+ const e = entity([
124
+ ServerContract.Types.task.from({
125
+ type: TaskType.BUILDPLOT,
126
+ duration: 100,
127
+ cancelable: TaskCancelable.ALWAYS,
128
+ cargo: [],
129
+ entitytarget: {entity_type: 'plot', entity_id: 55},
130
+ }),
131
+ ])
132
+ const plan = cancelEligibility(e, 0, 0, {now})
133
+ expect(plan.effects.keepsPlotDeposits?.plot.entity_id.toNumber()).toBe(55)
134
+ })
135
+
136
+ test('PULL load cancel refunds cargo to the giver', () => {
137
+ const lt = loadTask('warehouse', 6, 1, 4)
138
+ const upcoming = new Date('2026-06-18T23:59:50.000Z')
139
+ const e = ServerContract.Types.entity_info.from({
140
+ type: 'ship',
141
+ id: 1,
142
+ owner: 'player.gm',
143
+ entity_name: 'Ship 1',
144
+ coordinates: {x: 0, y: 0, z: 0},
145
+ item_id: 1,
146
+ cargomass: 0,
147
+ cargo: [],
148
+ modules: [],
149
+ lanes: [{lane_key: 0, schedule: {started: T0, tasks: [lt]}}],
150
+ holds: [
151
+ {
152
+ id: 1,
153
+ kind: HOLD_PULL,
154
+ counterpart: {entity_type: 'warehouse', entity_id: 6},
155
+ until: T0,
156
+ incoming_mass: 0,
157
+ },
158
+ ],
159
+ })
160
+ const plan = cancelEligibility(e, 0, 0, {now: upcoming})
161
+ expect(plan.effects.refunds[0]?.giver.entity_id.toNumber()).toBe(6)
162
+ expect(plan.effects.refunds[0]?.cargo[0].quantity.toNumber()).toBe(4)
163
+ })
164
+
165
+ test('PULL load cancel populates releasedHolds with kind and counterpart', () => {
166
+ const lt = loadTask('warehouse', 6, 1, 4)
167
+ const upcoming = new Date('2026-06-18T23:59:50.000Z')
168
+ const e = ServerContract.Types.entity_info.from({
169
+ type: 'ship',
170
+ id: 1,
171
+ owner: 'player.gm',
172
+ entity_name: 'Ship 1',
173
+ coordinates: {x: 0, y: 0, z: 0},
174
+ item_id: 1,
175
+ cargomass: 0,
176
+ cargo: [],
177
+ modules: [],
178
+ lanes: [{lane_key: 0, schedule: {started: T0, tasks: [lt]}}],
179
+ holds: [
180
+ {
181
+ id: 1,
182
+ kind: HOLD_PULL,
183
+ counterpart: {entity_type: 'warehouse', entity_id: 6},
184
+ until: T0,
185
+ incoming_mass: 0,
186
+ },
187
+ ],
188
+ })
189
+ const plan = cancelEligibility(e, 0, 0, {now: upcoming})
190
+ expect(plan.effects.releasedHolds[0]?.kind).toBe(1)
191
+ expect(plan.effects.releasedHolds[0]?.counterpart.entity_id.toNumber()).toBe(6)
192
+ })
193
+
194
+ test('unresolvable hold emits no releasedHolds entry', () => {
195
+ const lt = loadTask('warehouse', 6, 99, 4)
196
+ const upcoming = new Date('2026-06-18T23:59:50.000Z')
197
+ const e = ServerContract.Types.entity_info.from({
198
+ type: 'ship',
199
+ id: 1,
200
+ owner: 'player.gm',
201
+ entity_name: 'Ship 1',
202
+ coordinates: {x: 0, y: 0, z: 0},
203
+ item_id: 1,
204
+ cargomass: 0,
205
+ cargo: [],
206
+ modules: [],
207
+ lanes: [{lane_key: 0, schedule: {started: T0, tasks: [lt]}}],
208
+ holds: [],
209
+ })
210
+ const plan = cancelEligibility(e, 0, 0, {now: upcoming})
211
+ expect(plan.effects.releasedHolds).toHaveLength(0)
212
+ })
213
+ })
214
+
215
+ describe('cancelEligibility — feasibility', () => {
216
+ const upcoming = new Date('2026-06-18T23:59:50.000Z')
217
+
218
+ test('WOULD_STRAND when cancelling a producer a later consumer needs', () => {
219
+ const producer = ServerContract.Types.task.from({
220
+ type: TaskType.LOAD,
221
+ duration: 50,
222
+ cancelable: TaskCancelable.ALWAYS,
223
+ cargo: [{item_id: 7, stats: 0, modules: [], quantity: 2}],
224
+ })
225
+ const consumer = ServerContract.Types.task.from({
226
+ type: TaskType.CRAFT,
227
+ duration: 50,
228
+ cancelable: TaskCancelable.ALWAYS,
229
+ cargo: [
230
+ {item_id: 7, stats: 0, modules: [], quantity: 2},
231
+ {item_id: 9, stats: 0, modules: [], quantity: 1},
232
+ ],
233
+ })
234
+ const e = ServerContract.Types.entity_info.from({
235
+ type: 'ship',
236
+ id: 1,
237
+ owner: 'player.gm',
238
+ entity_name: 'S',
239
+ coordinates: {x: 0, y: 0, z: 0},
240
+ item_id: 1,
241
+ cargomass: 0,
242
+ cargo: [],
243
+ modules: [],
244
+ lanes: [
245
+ {
246
+ lane_key: 1,
247
+ schedule: {started: '2026-06-19T00:00:00', tasks: [producer]},
248
+ },
249
+ {
250
+ lane_key: 2,
251
+ schedule: {started: '2026-06-19T00:00:00', tasks: [consumer]},
252
+ },
253
+ ],
254
+ holds: [],
255
+ })
256
+ expect(cancelEligibility(e, 1, 0, {now: upcoming}).blockedReason).toBe(
257
+ CancelBlockReason.WOULD_STRAND
258
+ )
259
+ })
260
+
261
+ test('benign cancel on independent lane does NOT strand', () => {
262
+ const producer = ServerContract.Types.task.from({
263
+ type: TaskType.LOAD,
264
+ duration: 50,
265
+ cancelable: TaskCancelable.ALWAYS,
266
+ cargo: [{item_id: 7, stats: 0, modules: [], quantity: 2}],
267
+ })
268
+ const independent = ServerContract.Types.task.from({
269
+ type: TaskType.TRAVEL,
270
+ duration: 50,
271
+ cancelable: TaskCancelable.ALWAYS,
272
+ cargo: [],
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
+ {lane_key: 1, schedule: {started: '2026-06-19T00:00:00', tasks: [producer]}},
286
+ {lane_key: 2, schedule: {started: '2026-06-19T00:00:00', tasks: [independent]}},
287
+ ],
288
+ holds: [],
289
+ })
290
+ expect(cancelEligibility(e, 2, 0, {now: upcoming}).ok).toBe(true)
291
+ })
292
+
293
+ test('WOULD_STRAND when cancelling producer of a MODULAR cargo the consumer needs', () => {
294
+ const moduledCargo = {item_id: 7, stats: 0, modules: [{type: 3}], quantity: 2}
295
+ const producer = ServerContract.Types.task.from({
296
+ type: TaskType.LOAD,
297
+ duration: 50,
298
+ cancelable: TaskCancelable.ALWAYS,
299
+ cargo: [moduledCargo],
300
+ })
301
+ const consumer = ServerContract.Types.task.from({
302
+ type: TaskType.CRAFT,
303
+ duration: 50,
304
+ cancelable: TaskCancelable.ALWAYS,
305
+ cargo: [moduledCargo, {item_id: 9, stats: 0, modules: [], quantity: 1}],
306
+ })
307
+ const e = ServerContract.Types.entity_info.from({
308
+ type: 'ship',
309
+ id: 1,
310
+ owner: 'player.gm',
311
+ entity_name: 'S',
312
+ coordinates: {x: 0, y: 0, z: 0},
313
+ item_id: 1,
314
+ cargomass: 0,
315
+ cargo: [],
316
+ modules: [],
317
+ lanes: [
318
+ {lane_key: 1, schedule: {started: '2026-06-19T00:00:00', tasks: [producer]}},
319
+ {lane_key: 2, schedule: {started: '2026-06-19T00:00:00', tasks: [consumer]}},
320
+ ],
321
+ holds: [],
322
+ })
323
+ expect(cancelEligibility(e, 1, 0, {now: upcoming}).blockedReason).toBe(
324
+ CancelBlockReason.WOULD_STRAND
325
+ )
326
+ })
327
+ })
@@ -0,0 +1,209 @@
1
+ import type {ServerContract} from '../contracts'
2
+ import {TaskCancelable, TaskType} from '../types'
3
+ import {calcCargoItemMass} from '../capabilities/storage'
4
+ import {taskCargoEffect, cargoKey} from './availability'
5
+ import * as schedule from './schedule'
6
+ import {validateSchedule, type Projectable} from './projection'
7
+
8
+ export enum CancelBlockReason {
9
+ TASK_NEVER = 'TASK_NEVER',
10
+ BEFORE_START_RUNNING = 'BEFORE_START_RUNNING',
11
+ DONE = 'DONE',
12
+ CONTAINS_LINKED_TASK = 'CONTAINS_LINKED_TASK',
13
+ WOULD_STRAND = 'WOULD_STRAND',
14
+ WOULD_OVERFILL = 'WOULD_OVERFILL',
15
+ NOT_OWNER = 'NOT_OWNER',
16
+ }
17
+
18
+ type Task = InstanceType<typeof ServerContract.Types.task>
19
+ type EntityInfo = InstanceType<typeof ServerContract.Types.entity_info>
20
+ type EntityRef = InstanceType<typeof ServerContract.Types.entity_ref>
21
+ type CargoItem = InstanceType<typeof ServerContract.Types.cargo_item>
22
+
23
+ export interface CancelRefund {
24
+ giver: EntityRef
25
+ cargo: CargoItem[]
26
+ }
27
+ export interface CancelReleasedHold {
28
+ counterpart: EntityRef
29
+ kind: number
30
+ }
31
+ export interface CancelEffects {
32
+ refunds: CancelRefund[]
33
+ releasedHolds: CancelReleasedHold[]
34
+ abandonsRunning: boolean
35
+ keepsPlotDeposits?: {plot: EntityRef}
36
+ energyForfeited?: number
37
+ }
38
+ export interface CancelPlan {
39
+ ok: boolean
40
+ blockedReason?: CancelBlockReason
41
+ range: {count: number; taskIndices: number[]}
42
+ effects: CancelEffects
43
+ }
44
+ export interface CancelEligibilityInput {
45
+ now: Date
46
+ counterparts?: Map<string, EntityInfo>
47
+ }
48
+
49
+ const EMPTY_EFFECTS: CancelEffects = {refunds: [], releasedHolds: [], abandonsRunning: false}
50
+
51
+ function postCancelEntity(entity: EntityInfo, laneKey: number, fromTaskIndex: number): EntityInfo {
52
+ const clone = (entity.constructor as typeof ServerContract.Types.entity_info).from(
53
+ JSON.parse(JSON.stringify(entity.toJSON()))
54
+ ) as EntityInfo
55
+ const lane = clone.lanes.find((l) => l.lane_key.toNumber() === laneKey)!
56
+ lane.schedule.tasks = lane.schedule.tasks.slice(0, fromTaskIndex)
57
+ return clone
58
+ }
59
+
60
+ function feasibleAfterCancel(post: EntityInfo): boolean {
61
+ const ordered = schedule.orderedTasks(post as unknown as Projectable)
62
+ const base = new Map<string, number>()
63
+ for (const c of post.cargo ?? []) {
64
+ const k = cargoKey(c)
65
+ base.set(k, (base.get(k) ?? 0) + c.quantity.toNumber())
66
+ }
67
+ const isConsumer = (t: Task) =>
68
+ t.type.toNumber() === TaskType.CRAFT || t.type.toNumber() === TaskType.UNLOAD
69
+ for (const self of ordered) {
70
+ if (!isConsumer(self.task)) continue
71
+ const map = new Map(base)
72
+ for (const other of ordered) {
73
+ if (other.completesAt.getTime() >= self.completesAt.getTime()) continue
74
+ for (const out of taskCargoEffect(other.task).added) {
75
+ map.set(cargoKey(out), (map.get(cargoKey(out)) ?? 0) + out.quantity.toNumber())
76
+ }
77
+ }
78
+ for (const other of ordered) {
79
+ if (other === self) continue
80
+ for (const inp of taskCargoEffect(other.task).removed) {
81
+ const cur = map.get(cargoKey(inp)) ?? 0
82
+ map.set(cargoKey(inp), Math.max(0, cur - inp.quantity.toNumber()))
83
+ }
84
+ }
85
+ for (const inp of taskCargoEffect(self.task).removed) {
86
+ if ((map.get(cargoKey(inp)) ?? 0) < inp.quantity.toNumber()) return false
87
+ }
88
+ }
89
+ try {
90
+ validateSchedule(post as unknown as Projectable)
91
+ } catch {
92
+ return false
93
+ }
94
+ return true
95
+ }
96
+
97
+ interface Timing {
98
+ startsAt: number
99
+ completesAt: number
100
+ running: boolean
101
+ done: boolean
102
+ }
103
+
104
+ function laneTiming(
105
+ lane: {schedule: {started: {toDate(): Date}; tasks: Task[]}},
106
+ nowMs: number
107
+ ): Timing[] {
108
+ const startedMs = lane.schedule.started.toDate().getTime()
109
+ let endSec = 0
110
+ return lane.schedule.tasks.map((t) => {
111
+ const startsAt = startedMs + endSec * 1000
112
+ endSec += t.duration.toNumber()
113
+ const completesAt = startedMs + endSec * 1000
114
+ return {
115
+ startsAt,
116
+ completesAt,
117
+ running: nowMs >= startsAt && nowMs < completesAt,
118
+ done: nowMs >= completesAt,
119
+ }
120
+ })
121
+ }
122
+
123
+ export function cancelEligibility(
124
+ entity: EntityInfo,
125
+ laneKey: number,
126
+ fromTaskIndex: number,
127
+ input: CancelEligibilityInput
128
+ ): CancelPlan {
129
+ const lane = (entity.lanes ?? []).find((l) => l.lane_key.equals(laneKey))
130
+ if (!lane || fromTaskIndex < 0 || fromTaskIndex >= lane.schedule.tasks.length) {
131
+ return {ok: false, range: {count: 0, taskIndices: []}, effects: {...EMPTY_EFFECTS}}
132
+ }
133
+
134
+ const tasks = lane.schedule.tasks
135
+ const timing = laneTiming(lane, input.now.getTime())
136
+ const taskIndices: number[] = []
137
+ for (let i = fromTaskIndex; i < tasks.length; i++) taskIndices.push(i)
138
+ const range = {count: taskIndices.length, taskIndices}
139
+
140
+ const block = (blockedReason: CancelBlockReason): CancelPlan => ({
141
+ ok: false,
142
+ blockedReason,
143
+ range,
144
+ effects: {...EMPTY_EFFECTS},
145
+ })
146
+
147
+ for (const i of taskIndices) {
148
+ const t = tasks[i]
149
+ if (t.entitygroup && !t.entitygroup.equals(0))
150
+ return block(CancelBlockReason.CONTAINS_LINKED_TASK)
151
+ }
152
+
153
+ for (const i of taskIndices) {
154
+ const t = tasks[i]
155
+ if (timing[i].done) return block(CancelBlockReason.DONE)
156
+ if (t.cancelable.equals(TaskCancelable.NEVER) && !t.type.equals(TaskType.IDLE))
157
+ return block(CancelBlockReason.TASK_NEVER)
158
+ if (t.cancelable.equals(TaskCancelable.BEFORE_START) && timing[i].running)
159
+ return block(CancelBlockReason.BEFORE_START_RUNNING)
160
+ }
161
+
162
+ const post = postCancelEntity(entity, laneKey, fromTaskIndex)
163
+ if (!feasibleAfterCancel(post)) return block(CancelBlockReason.WOULD_STRAND)
164
+
165
+ const effects: CancelEffects = {refunds: [], releasedHolds: [], abandonsRunning: false}
166
+ let energyForfeited = 0
167
+ for (const i of taskIndices) {
168
+ const t = tasks[i]
169
+ if (timing[i].running && t.cancelable.equals(TaskCancelable.ALWAYS))
170
+ effects.abandonsRunning = true
171
+ if (t.energy_cost) energyForfeited += t.energy_cost.toNumber()
172
+ if (t.type.equals(TaskType.BUILDPLOT) && t.entitytarget)
173
+ effects.keepsPlotDeposits = {plot: t.entitytarget}
174
+ if (t.hold && t.entitytarget) {
175
+ const counterpart = input.counterparts?.get(t.entitytarget.entity_id.toString())
176
+ const hold =
177
+ (counterpart?.holds ?? []).find((h) => h.id.equals(t.hold!)) ??
178
+ (entity.holds ?? []).find((h) => h.id.equals(t.hold!))
179
+ if (hold) {
180
+ const kind = hold.kind.toNumber()
181
+ effects.releasedHolds.push({counterpart: t.entitytarget, kind})
182
+ if (kind === 1 /* HOLD_PULL */) {
183
+ effects.refunds.push({giver: t.entitytarget, cargo: t.cargo})
184
+ const giver = counterpart
185
+ if (giver) {
186
+ const returned = t.cargo.reduce(
187
+ (s, c) => s + calcCargoItemMass(c).toNumber(),
188
+ 0
189
+ )
190
+ const cap = giver.capacity
191
+ ? giver.capacity.toNumber()
192
+ : Number.MAX_SAFE_INTEGER
193
+ if (giver.cargomass.toNumber() + returned > cap) {
194
+ return {
195
+ ok: false,
196
+ blockedReason: CancelBlockReason.WOULD_OVERFILL,
197
+ range,
198
+ effects: {...EMPTY_EFFECTS},
199
+ }
200
+ }
201
+ }
202
+ }
203
+ }
204
+ }
205
+ }
206
+ if (energyForfeited > 0) effects.energyForfeited = energyForfeited
207
+
208
+ return {ok: true, range, effects}
209
+ }
@@ -31,13 +31,14 @@ export function composeIdleResolve(
31
31
  add(blocker.id)
32
32
  }
33
33
 
34
- for (const hold of blocker.holds ?? []) {
35
- const counterpartId = hold.counterpart.entity_id
36
- if (lookupCounterpart) {
34
+ // Without a lookup we cannot confirm the counterpart has a completed task, so skip it.
35
+ if (lookupCounterpart) {
36
+ for (const hold of blocker.holds ?? []) {
37
+ const counterpartId = hold.counterpart.entity_id
37
38
  const counterpart = lookupCounterpart(counterpartId)
38
39
  if (!counterpart || !hasResolvable(counterpart, now)) continue
40
+ add(counterpartId)
39
41
  }
40
- add(counterpartId)
41
42
  }
42
43
 
43
44
  return [...ids.map((id) => actions.resolve(id)), action]
@@ -303,6 +303,7 @@ function applyTask(projected: ProjectedEntity, task: ServerContract.Types.task):
303
303
  break
304
304
  case TaskType.TRAVEL:
305
305
  case TaskType.WARP:
306
+ case TaskType.TRANSIT:
306
307
  applyFlightTask(projected, task, {complete: true})
307
308
  break
308
309
  case TaskType.LOAD:
@@ -461,6 +462,7 @@ export function projectEntityAt(entity: Projectable, now: Date): ProjectedEntity
461
462
  break
462
463
  case TaskType.TRAVEL:
463
464
  case TaskType.WARP:
465
+ case TaskType.TRANSIT:
464
466
  applyFlightTask(projected, task, {complete: taskComplete, progress})
465
467
  break
466
468
  case TaskType.LOAD:
@@ -294,7 +294,9 @@ function entityDoesTaskType(entity: ScheduleData, taskType: TaskType, now: Date)
294
294
 
295
295
  export function isInFlight(entity: ScheduleData, now: Date): boolean {
296
296
  const lane = mobilityLane(entity)
297
- return lane ? core.currentTaskType(lane.schedule, now) === TaskType.TRAVEL : false
297
+ if (!lane) return false
298
+ const t = core.currentTaskType(lane.schedule, now)
299
+ return t === TaskType.TRAVEL || t === TaskType.TRANSIT
298
300
  }
299
301
 
300
302
  export function isRecharging(entity: ScheduleData, now: Date): boolean {