@shipload/sdk 1.0.0-next.26 → 1.0.0-next.27

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.
@@ -4,6 +4,7 @@ import type {ServerContract} from '../contracts'
4
4
  import {PlotManager} from './plot'
5
5
  import {getItem} from '../data/catalog'
6
6
  import {calc_craft_duration} from '../capabilities/crafting'
7
+ import {getLanes, getTasks} from '../scheduling/schedule'
7
8
  import {TaskType} from '../types'
8
9
  import type {
9
10
  BuildableTarget,
@@ -94,43 +95,43 @@ export class ConstructionManager extends BaseManager {
94
95
  const buckets = new Map<string, Map<string, InboundTransfer>>()
95
96
  const nowMs = now.getTime()
96
97
  for (const entity of entities) {
97
- const schedule = entity.schedule
98
- if (!schedule) continue
99
98
  const entityIdStr = entity.id.toString()
100
99
  const sourceName = entity.entity_name || entityIdStr
101
- const startedMs = schedule.started.toDate().getTime()
102
- let cumulativeSec = 0
103
- for (const task of schedule.tasks) {
104
- cumulativeSec += task.duration.toNumber()
105
- if (!isTransferTask(task)) continue
106
- if (!task.entitytarget) continue
107
- const projectedEndMs = startedMs + cumulativeSec * 1000
108
- if (projectedEndMs < nowMs) continue
109
- const targetIdStr = task.entitytarget.entity_id.toString()
110
- const etaSeconds = Math.max(0, Math.round((projectedEndMs - nowMs) / 1000))
111
- let perTarget = buckets.get(targetIdStr)
112
- if (!perTarget) {
113
- perTarget = new Map()
114
- buckets.set(targetIdStr, perTarget)
115
- }
116
- for (const c of task.cargo) {
117
- const itemId = c.item_id.toNumber()
118
- const quantity = c.quantity.toNumber()
119
- if (quantity === 0) continue
120
- const key = `${entityIdStr}#${itemId}`
121
- const existing = perTarget.get(key)
122
- if (existing) {
123
- existing.quantity += quantity
124
- existing.etaSeconds = Math.min(existing.etaSeconds, etaSeconds)
125
- } else {
126
- perTarget.set(key, {
127
- sourceEntityId: entity.id,
128
- sourceEntityType: entity.type,
129
- sourceName,
130
- itemId,
131
- quantity,
132
- etaSeconds,
133
- })
100
+ for (const lane of getLanes(entity)) {
101
+ const startedMs = lane.schedule.started.toDate().getTime()
102
+ let cumulativeSec = 0
103
+ for (const task of lane.schedule.tasks) {
104
+ cumulativeSec += task.duration.toNumber()
105
+ if (!isTransferTask(task)) continue
106
+ if (!task.entitytarget) continue
107
+ const projectedEndMs = startedMs + cumulativeSec * 1000
108
+ if (projectedEndMs < nowMs) continue
109
+ const targetIdStr = task.entitytarget.entity_id.toString()
110
+ const etaSeconds = Math.max(0, Math.round((projectedEndMs - nowMs) / 1000))
111
+ let perTarget = buckets.get(targetIdStr)
112
+ if (!perTarget) {
113
+ perTarget = new Map()
114
+ buckets.set(targetIdStr, perTarget)
115
+ }
116
+ for (const c of task.cargo) {
117
+ const itemId = c.item_id.toNumber()
118
+ const quantity = c.quantity.toNumber()
119
+ if (quantity === 0) continue
120
+ const key = `${entityIdStr}#${itemId}`
121
+ const existing = perTarget.get(key)
122
+ if (existing) {
123
+ existing.quantity += quantity
124
+ existing.etaSeconds = Math.min(existing.etaSeconds, etaSeconds)
125
+ } else {
126
+ perTarget.set(key, {
127
+ sourceEntityId: entity.id,
128
+ sourceEntityType: entity.type,
129
+ sourceName,
130
+ itemId,
131
+ quantity,
132
+ etaSeconds,
133
+ })
134
+ }
134
135
  }
135
136
  }
136
137
  }
@@ -152,25 +153,24 @@ export class ConstructionManager extends BaseManager {
152
153
  completesAt: number
153
154
  hasStarted: boolean
154
155
  } | null {
155
- const schedule = plot.schedule
156
- if (!schedule) return null
157
- const tasks = schedule.tasks
158
- const startedMs = schedule.started.toDate().getTime()
159
- let startSec = 0
160
- for (const task of tasks) {
161
- if (task.type.toNumber() === TaskType.RESERVED) {
162
- if (!task.entitytarget) return null
163
- const startsAt = startedMs + startSec * 1000
164
- const completesAt = startsAt + task.duration.toNumber() * 1000
165
- return {
166
- builderId: task.entitytarget.entity_id,
167
- group: task.entitygroup ?? undefined,
168
- startsAt,
169
- completesAt,
170
- hasStarted: startsAt <= now.getTime(),
156
+ for (const lane of getLanes(plot)) {
157
+ const startedMs = lane.schedule.started.toDate().getTime()
158
+ let startSec = 0
159
+ for (const task of lane.schedule.tasks) {
160
+ if (task.type.toNumber() === TaskType.RESERVED) {
161
+ if (!task.entitytarget) return null
162
+ const startsAt = startedMs + startSec * 1000
163
+ const completesAt = startsAt + task.duration.toNumber() * 1000
164
+ return {
165
+ builderId: task.entitytarget.entity_id,
166
+ group: task.entitygroup ?? undefined,
167
+ startsAt,
168
+ completesAt,
169
+ hasStarted: startsAt <= now.getTime(),
170
+ }
171
171
  }
172
+ startSec += task.duration.toNumber()
172
173
  }
173
- startSec += task.duration.toNumber()
174
174
  }
175
175
  return null
176
176
  }
@@ -179,19 +179,22 @@ export class ConstructionManager extends BaseManager {
179
179
  builder: ServerContract.Types.entity_info | undefined,
180
180
  group: UInt64 | undefined
181
181
  ): {cancelable: boolean; blockingTaskCount: number} {
182
- if (!builder?.schedule || group === undefined) {
182
+ if (!builder || group === undefined) {
183
183
  return {cancelable: false, blockingTaskCount: 0}
184
184
  }
185
- const tasks = builder.schedule.tasks
186
- const buildIdx = tasks.findIndex(
187
- (t) =>
188
- t.type.toNumber() === TaskType.BUILDPLOT &&
189
- t.entitygroup !== undefined &&
190
- t.entitygroup.equals(group)
191
- )
192
- if (buildIdx < 0) return {cancelable: false, blockingTaskCount: 0}
193
- const trailing = tasks.length - 1 - buildIdx
194
- return {cancelable: trailing === 0, blockingTaskCount: trailing}
185
+ for (const lane of getLanes(builder)) {
186
+ const tasks = lane.schedule.tasks
187
+ const buildIdx = tasks.findIndex(
188
+ (t) =>
189
+ t.type.toNumber() === TaskType.BUILDPLOT &&
190
+ t.entitygroup !== undefined &&
191
+ t.entitygroup.equals(group)
192
+ )
193
+ if (buildIdx < 0) continue
194
+ const trailing = tasks.length - 1 - buildIdx
195
+ return {cancelable: trailing === 0, blockingTaskCount: trailing}
196
+ }
197
+ return {cancelable: false, blockingTaskCount: 0}
195
198
  }
196
199
 
197
200
  private buildFromReservation(
@@ -357,9 +360,8 @@ function isTransferTask(task: ServerContract.Types.task): boolean {
357
360
  }
358
361
 
359
362
  function reservationsOf(source: ServerContract.Types.entity_info): Reservation[] {
360
- if (!source.schedule) return []
361
363
  const out = new Map<string, Reservation>()
362
- for (const task of source.schedule.tasks) {
364
+ for (const task of getTasks(source)) {
363
365
  if (!isTransferTask(task)) continue
364
366
  if (!task.entitytarget) continue
365
367
  const targetType = task.entitytarget.entity_type
@@ -1,86 +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
73
112
  }
74
113
 
75
114
  currentTaskProgressFloat(now: Date): number {
76
- return schedule.currentTaskProgressFloat(this.entity, now)
115
+ return this.lane ? core.currentTaskProgressFloatForLane(this.lane.schedule, now) : 0
77
116
  }
78
117
 
79
118
  progress(now: Date): number {
80
- return schedule.scheduleProgress(this.entity, now)
119
+ return this.lane ? core.laneProgress(this.lane.schedule, now) : 0
81
120
  }
82
121
  }
83
122
 
84
- export function createScheduleAccessor(entity: ScheduleData): ScheduleAccessor {
85
- return new ScheduleAccessor(entity)
123
+ export function createScheduleAccessor(
124
+ entity: ScheduleData,
125
+ laneKey: number = LANE_MOBILITY
126
+ ): ScheduleAccessor {
127
+ return new ScheduleAccessor(entity, laneKey)
86
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
+ 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
+ }
@@ -1,6 +1,6 @@
1
1
  import {TaskType} from '../types'
2
2
  import {createProjectedEntity, type Projectable} from './projection'
3
- import {currentTaskIndex, currentTaskProgressFloat, isTaskComplete} from './schedule'
3
+ import {orderedTasks} from './schedule'
4
4
 
5
5
  export function energyAtTime(entity: Projectable, now: Date): number {
6
6
  const projected = createProjectedEntity(entity)
@@ -13,24 +13,31 @@ export function energyAtTime(entity: Projectable, now: Date): number {
13
13
 
14
14
  let running = Number(projected.energy)
15
15
 
16
- const tasks = entity.schedule?.tasks
17
- if (!tasks || tasks.length === 0) return clamp(running)
16
+ const ordered = orderedTasks(entity)
17
+ if (ordered.length === 0) return clamp(running)
18
18
 
19
- const activeIndex = currentTaskIndex(entity, now)
20
- const activeProgress = currentTaskProgressFloat(entity, now)
19
+ const nowMs = now.getTime()
21
20
 
22
- for (let i = 0; i < tasks.length; i++) {
23
- const complete = isTaskComplete(entity, i, now)
24
- if (!complete && i !== activeIndex) break
21
+ for (const {task, startsAt} of ordered) {
22
+ const duration = task.duration.toNumber()
23
+ const isReserved = task.type.toNumber() === TaskType.RESERVED
24
+ const elapsed = Math.min(
25
+ Math.max(0, Math.floor((nowMs - startsAt.getTime()) / 1000)),
26
+ duration
27
+ )
28
+ const complete = !isReserved && elapsed >= duration
29
+ const inProgress = !complete && elapsed > 0 && elapsed < duration
25
30
 
26
- const fraction = complete ? 1 : activeProgress
31
+ if (!complete && !inProgress) continue
27
32
 
28
- if (tasks[i].type.toNumber() === TaskType.RECHARGE) {
33
+ const fraction = complete ? 1 : duration === 0 ? 1 : elapsed / duration
34
+
35
+ if (task.type.toNumber() === TaskType.RECHARGE) {
29
36
  if (capacity !== undefined) {
30
37
  running = complete ? capacity : running + (capacity - running) * fraction
31
38
  }
32
39
  } else {
33
- const cost = Number(tasks[i].energy_cost ?? 0)
40
+ const cost = Number(task.energy_cost ?? 0)
34
41
  running -= cost * fraction
35
42
  }
36
43
 
@@ -0,0 +1,130 @@
1
+ import type {ServerContract} from '../contracts'
2
+ import {TaskType} from '../types'
3
+
4
+ type Schedule = ServerContract.Types.schedule
5
+ type Task = ServerContract.Types.task
6
+
7
+ export function laneDuration(schedule: Schedule): number {
8
+ return schedule.tasks.reduce((sum, task) => sum + task.duration.toNumber(), 0)
9
+ }
10
+
11
+ export function laneRawElapsed(schedule: Schedule, now: Date): number {
12
+ const started = schedule.started.toDate()
13
+ return Math.floor((now.getTime() - started.getTime()) / 1000)
14
+ }
15
+
16
+ export function laneElapsed(schedule: Schedule, now: Date): number {
17
+ return Math.max(0, laneRawElapsed(schedule, now))
18
+ }
19
+
20
+ export function laneStartsIn(schedule: Schedule, now: Date): number {
21
+ return Math.max(0, -laneRawElapsed(schedule, now))
22
+ }
23
+
24
+ export function laneRemaining(schedule: Schedule, now: Date): number {
25
+ return Math.max(0, laneDuration(schedule) - laneRawElapsed(schedule, now))
26
+ }
27
+
28
+ export function laneComplete(schedule: Schedule, now: Date): boolean {
29
+ if (schedule.tasks.length === 0) return false
30
+ if (schedule.tasks.some((t) => t.type.toNumber() === TaskType.RESERVED)) return false
31
+ return laneRemaining(schedule, now) === 0
32
+ }
33
+
34
+ export function laneProgress(schedule: Schedule, now: Date): number {
35
+ const duration = laneDuration(schedule)
36
+ if (duration === 0) return schedule.tasks.length > 0 ? 1 : 0
37
+ return Math.min(1, laneElapsed(schedule, now) / duration)
38
+ }
39
+
40
+ export function currentTaskIndexForLane(schedule: Schedule, now: Date): number {
41
+ if (schedule.tasks.length === 0) return -1
42
+ if (laneRawElapsed(schedule, now) < 0) return -1
43
+ const elapsed = laneElapsed(schedule, now)
44
+ let timeAccum = 0
45
+ for (let i = 0; i < schedule.tasks.length; i++) {
46
+ const taskDuration = schedule.tasks[i].duration.toNumber()
47
+ if (elapsed < timeAccum + taskDuration) return i
48
+ timeAccum += taskDuration
49
+ }
50
+ return -1
51
+ }
52
+
53
+ export function currentTask(schedule: Schedule, now: Date): Task | undefined {
54
+ const index = currentTaskIndexForLane(schedule, now)
55
+ if (index < 0) return undefined
56
+ return schedule.tasks[index]
57
+ }
58
+
59
+ export function currentTaskType(schedule: Schedule, now: Date): TaskType | undefined {
60
+ const task = currentTask(schedule, now)
61
+ return task ? (task.type.toNumber() as TaskType) : undefined
62
+ }
63
+
64
+ export function laneTaskStartTime(schedule: Schedule, index: number): number {
65
+ if (index < 0 || index >= schedule.tasks.length) return 0
66
+ let timeAccum = 0
67
+ for (let i = 0; i < index; i++) {
68
+ timeAccum += schedule.tasks[i].duration.toNumber()
69
+ }
70
+ return timeAccum
71
+ }
72
+
73
+ export function laneTaskElapsed(schedule: Schedule, index: number, now: Date): number {
74
+ if (index < 0 || index >= schedule.tasks.length) return 0
75
+ const elapsed = laneElapsed(schedule, now)
76
+ const taskStart = laneTaskStartTime(schedule, index)
77
+ const taskDuration = schedule.tasks[index].duration.toNumber()
78
+ if (elapsed <= taskStart) return 0
79
+ return Math.min(elapsed - taskStart, taskDuration)
80
+ }
81
+
82
+ export function laneTaskRemaining(schedule: Schedule, index: number, now: Date): number {
83
+ if (index < 0 || index >= schedule.tasks.length) return 0
84
+ const taskDuration = schedule.tasks[index].duration.toNumber()
85
+ return Math.max(0, taskDuration - laneTaskElapsed(schedule, index, now))
86
+ }
87
+
88
+ export function laneTaskComplete(schedule: Schedule, index: number, now: Date): boolean {
89
+ if (index < 0 || index >= schedule.tasks.length) return false
90
+ if (schedule.tasks[index].type.toNumber() === TaskType.RESERVED) return false
91
+ const taskDuration = schedule.tasks[index].duration.toNumber()
92
+ return laneTaskElapsed(schedule, index, now) >= taskDuration
93
+ }
94
+
95
+ export function laneTaskInProgress(schedule: Schedule, index: number, now: Date): boolean {
96
+ if (index < 0 || index >= schedule.tasks.length) return false
97
+ const taskElapsed = laneTaskElapsed(schedule, index, now)
98
+ const taskDuration = schedule.tasks[index].duration.toNumber()
99
+ return taskElapsed > 0 && taskElapsed < taskDuration
100
+ }
101
+
102
+ export function laneCompletesAt(schedule: Schedule, index: number): Date {
103
+ const startedMs = schedule.started.toDate().getTime()
104
+ const endSec =
105
+ laneTaskStartTime(schedule, index) + (schedule.tasks[index]?.duration.toNumber() ?? 0)
106
+ return new Date(startedMs + endSec * 1000)
107
+ }
108
+
109
+ export function currentTaskProgress(schedule: Schedule, now: Date): number {
110
+ const index = currentTaskIndexForLane(schedule, now)
111
+ if (index < 0) return 0
112
+ const elapsed = laneTaskElapsed(schedule, index, now)
113
+ const duration = schedule.tasks[index].duration.toNumber()
114
+ if (duration === 0) return 1
115
+ return Math.min(1, elapsed / duration)
116
+ }
117
+
118
+ export function currentTaskProgressFloatForLane(schedule: Schedule, now: Date): number {
119
+ if (schedule.tasks.length === 0) return 0
120
+ const index = currentTaskIndexForLane(schedule, now)
121
+ if (index < 0) return 0
122
+ const task = schedule.tasks[index]
123
+ const durationMs = task.duration.toNumber() * 1000
124
+ if (durationMs === 0) return 1
125
+ const startedMs = schedule.started.toDate().getTime()
126
+ const taskStartMs = startedMs + laneTaskStartTime(schedule, index) * 1000
127
+ const elapsedMs = now.getTime() - taskStartMs
128
+ if (elapsedMs <= 0) return 0
129
+ return Math.min(1, elapsedMs / durationMs)
130
+ }