@shipload/sdk 1.0.0-next.6 → 1.0.0-next.60

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 (155) hide show
  1. package/lib/scan.d.ts +52 -0
  2. package/lib/scan.js +162 -0
  3. package/lib/scan.js.map +1 -0
  4. package/lib/scan.m.js +152 -0
  5. package/lib/scan.m.js.map +1 -0
  6. package/lib/shipload.d.ts +3589 -1363
  7. package/lib/shipload.js +15700 -5454
  8. package/lib/shipload.js.map +1 -1
  9. package/lib/shipload.m.js +15423 -5402
  10. package/lib/shipload.m.js.map +1 -1
  11. package/lib/testing.d.ts +1173 -0
  12. package/lib/testing.js +4947 -0
  13. package/lib/testing.js.map +1 -0
  14. package/lib/testing.m.js +4941 -0
  15. package/lib/testing.m.js.map +1 -0
  16. package/package.json +20 -2
  17. package/src/capabilities/craftable.test.ts +82 -0
  18. package/src/capabilities/craftable.ts +82 -0
  19. package/src/capabilities/crafting.test.ts +35 -0
  20. package/src/capabilities/crafting.ts +34 -7
  21. package/src/capabilities/gathering.test.ts +29 -0
  22. package/src/capabilities/gathering.ts +37 -19
  23. package/src/capabilities/hauling.ts +0 -5
  24. package/src/capabilities/index.ts +0 -1
  25. package/src/capabilities/modules.ts +42 -0
  26. package/src/capabilities/movement.ts +1 -1
  27. package/src/capabilities/storage.ts +16 -1
  28. package/src/contracts/platform.ts +231 -3
  29. package/src/contracts/server.ts +1518 -501
  30. package/src/coordinates/address.ts +88 -0
  31. package/src/coordinates/constants.test.ts +15 -0
  32. package/src/coordinates/constants.ts +23 -0
  33. package/src/coordinates/index.ts +15 -0
  34. package/src/coordinates/memo.test.ts +47 -0
  35. package/src/coordinates/memo.ts +20 -0
  36. package/src/coordinates/permutation.ts +77 -0
  37. package/src/coordinates/regions.ts +48 -0
  38. package/src/coordinates/sectors.ts +115 -0
  39. package/src/data/capabilities.ts +49 -18
  40. package/src/data/capability-formulas.ts +26 -14
  41. package/src/data/catalog.ts +0 -5
  42. package/src/data/colors.ts +13 -47
  43. package/src/data/entities.json +379 -14
  44. package/src/data/item-ids.ts +48 -12
  45. package/src/data/items.json +301 -74
  46. package/src/data/kind-registry.json +195 -0
  47. package/src/data/kind-registry.ts +182 -0
  48. package/src/data/metadata.ts +277 -48
  49. package/src/data/recipes-runtime.ts +6 -23
  50. package/src/data/recipes.json +2074 -173
  51. package/src/derivation/build-methods.test.ts +13 -0
  52. package/src/derivation/build-methods.ts +48 -0
  53. package/src/derivation/capabilities.test.ts +348 -0
  54. package/src/derivation/capabilities.ts +692 -0
  55. package/src/derivation/capability-mappings.ts +60 -16
  56. package/src/derivation/crafting.test.ts +17 -0
  57. package/src/derivation/crafting.ts +56 -33
  58. package/src/derivation/index.ts +27 -2
  59. package/src/derivation/recipe-usage.test.ts +88 -0
  60. package/src/derivation/recipe-usage.ts +141 -0
  61. package/src/derivation/reserve-regen.ts +34 -0
  62. package/src/derivation/resources.ts +9 -1
  63. package/src/derivation/rollups.test.ts +55 -0
  64. package/src/derivation/rollups.ts +72 -0
  65. package/src/derivation/stars.test.ts +67 -0
  66. package/src/derivation/stars.ts +25 -0
  67. package/src/derivation/stat-scaling.ts +12 -0
  68. package/src/derivation/stats.ts +6 -6
  69. package/src/derivation/stratum.ts +26 -22
  70. package/src/derivation/tiers.ts +40 -7
  71. package/src/derivation/upgrades.ts +14 -0
  72. package/src/derivation/wormhole.ts +154 -0
  73. package/src/entities/entity.ts +102 -0
  74. package/src/entities/gamestate.ts +3 -28
  75. package/src/entities/makers.ts +141 -135
  76. package/src/entities/slot-multiplier.ts +43 -0
  77. package/src/errors.ts +12 -16
  78. package/src/format.ts +26 -4
  79. package/src/index-module.ts +333 -35
  80. package/src/managers/actions.ts +668 -103
  81. package/src/managers/base.ts +6 -2
  82. package/src/managers/cluster.test.ts +39 -0
  83. package/src/managers/cluster.ts +53 -0
  84. package/src/managers/construction-types.ts +80 -0
  85. package/src/managers/construction.ts +443 -0
  86. package/src/managers/context.ts +29 -1
  87. package/src/managers/coordinates.ts +14 -0
  88. package/src/managers/entities.ts +27 -66
  89. package/src/managers/epochs.ts +40 -0
  90. package/src/managers/index.ts +20 -1
  91. package/src/managers/locations.ts +25 -29
  92. package/src/managers/nft.test.ts +14 -0
  93. package/src/managers/nft.ts +70 -0
  94. package/src/managers/players.ts +25 -0
  95. package/src/managers/plot.ts +122 -0
  96. package/src/nft/atomicassets.abi.json +1342 -0
  97. package/src/nft/atomicassets.ts +237 -0
  98. package/src/nft/atomicdata.ts +130 -0
  99. package/src/nft/buildImmutableData.ts +385 -0
  100. package/src/nft/description.ts +225 -52
  101. package/src/nft/index.ts +3 -0
  102. package/src/planner/index.ts +242 -0
  103. package/src/planner/planner.test.ts +334 -0
  104. package/src/resolution/describe-module.ts +43 -25
  105. package/src/resolution/display-name.ts +38 -10
  106. package/src/resolution/resolve-item.test.ts +48 -0
  107. package/src/resolution/resolve-item.ts +112 -58
  108. package/src/scan/index.ts +244 -0
  109. package/src/scan/scan-wasm.base64.ts +2 -0
  110. package/src/scheduling/accessor.ts +65 -23
  111. package/src/scheduling/availability.test.ts +204 -0
  112. package/src/scheduling/availability.ts +211 -0
  113. package/src/scheduling/cancel.test.ts +542 -0
  114. package/src/scheduling/cancel.ts +254 -0
  115. package/src/scheduling/cluster-stock.test.ts +31 -0
  116. package/src/scheduling/cluster-stock.ts +15 -0
  117. package/src/scheduling/energy.ts +47 -0
  118. package/src/scheduling/idle-resolve.ts +45 -0
  119. package/src/scheduling/jobs.ts +71 -0
  120. package/src/scheduling/lane-core.ts +128 -0
  121. package/src/scheduling/lanes.test.ts +250 -0
  122. package/src/scheduling/lanes.ts +227 -0
  123. package/src/scheduling/projection.ts +257 -133
  124. package/src/scheduling/schedule.test.ts +119 -0
  125. package/src/scheduling/schedule.ts +257 -117
  126. package/src/scheduling/task-cargo.test.ts +44 -0
  127. package/src/scheduling/task-cargo.ts +46 -0
  128. package/src/scheduling/unwrap.test.ts +86 -0
  129. package/src/scheduling/unwrap.ts +190 -0
  130. package/src/shipload.ts +26 -1
  131. package/src/subscriptions/manager.cluster.test.ts +51 -0
  132. package/src/subscriptions/manager.ts +245 -172
  133. package/src/subscriptions/mappers.ts +5 -8
  134. package/src/subscriptions/types.ts +28 -3
  135. package/src/testing/catalog-hash.ts +19 -0
  136. package/src/testing/index.ts +2 -0
  137. package/src/testing/projection-parity.ts +167 -0
  138. package/src/travel/reach.ts +21 -0
  139. package/src/travel/route-planner.ts +288 -0
  140. package/src/travel/route-simulator.ts +170 -0
  141. package/src/travel/travel.ts +188 -122
  142. package/src/types/capabilities.ts +37 -8
  143. package/src/types/entity.ts +3 -3
  144. package/src/types/index.ts +0 -1
  145. package/src/types.ts +51 -16
  146. package/src/utils/cargo.test.ts +14 -0
  147. package/src/utils/cargo.ts +29 -0
  148. package/src/utils/display-name.ts +70 -0
  149. package/src/utils/system.ts +15 -16
  150. package/src/capabilities/loading.ts +0 -8
  151. package/src/entities/container.ts +0 -123
  152. package/src/entities/ship-deploy.ts +0 -295
  153. package/src/entities/ship.ts +0 -221
  154. package/src/entities/warehouse.ts +0 -127
  155. package/src/types/entity-traits.ts +0 -69
@@ -0,0 +1,119 @@
1
+ import {describe, expect, test} from 'bun:test'
2
+ import {ServerContract, TaskType} from '../index-module'
3
+ import {hasPendingCapper, hasResolvable, isCapperTaskType} from './schedule'
4
+
5
+ const T0 = '2026-06-19T00:00:00'
6
+ const NOW = new Date('2026-06-19T00:01:00.000Z')
7
+
8
+ function task(over: Partial<{type: number; duration: number}>) {
9
+ return ServerContract.Types.task.from({
10
+ type: over.type ?? TaskType.TRAVEL,
11
+ duration: over.duration ?? 30,
12
+ cancelable: 0,
13
+ cargo: [],
14
+ couplings: [],
15
+ })
16
+ }
17
+
18
+ function entity(
19
+ tasks: ReturnType<typeof task>[],
20
+ holds: ServerContract.Types.hold[] = [],
21
+ startedISO = T0
22
+ ) {
23
+ return ServerContract.Types.entity_info.from({
24
+ type: 'ship',
25
+ id: 1,
26
+ owner: 'player.gm',
27
+ entity_name: 'Ship 1',
28
+ coordinates: {x: 0, y: 0, z: 0},
29
+ item_id: 1,
30
+ cargomass: 0,
31
+ cargo: [],
32
+ modules: [],
33
+ lanes: [{lane_key: 0, schedule: {started: startedISO, tasks}}],
34
+ gatherer_lanes: [],
35
+ crafter_lanes: [],
36
+ builder_lanes: [],
37
+ loader_lanes: [],
38
+ holds,
39
+ })
40
+ }
41
+
42
+ function hold(kind: number) {
43
+ return ServerContract.Types.hold.from({
44
+ id: 1,
45
+ kind,
46
+ counterpart: {entity_type: 'ship', entity_id: 2},
47
+ until: T0,
48
+ incoming_mass: 0,
49
+ })
50
+ }
51
+
52
+ describe('isCapperTaskType', () => {
53
+ test('UNDEPLOY, DEMOLISH are cappers', () => {
54
+ expect(isCapperTaskType(TaskType.UNDEPLOY)).toBe(true)
55
+ expect(isCapperTaskType(TaskType.DEMOLISH)).toBe(true)
56
+ })
57
+
58
+ test('REFIT is not a capper', () => {
59
+ expect(isCapperTaskType(TaskType.REFIT)).toBe(false)
60
+ })
61
+
62
+ test('non-capper task types are not cappers', () => {
63
+ expect(isCapperTaskType(TaskType.TRAVEL)).toBe(false)
64
+ expect(isCapperTaskType(TaskType.LOAD)).toBe(false)
65
+ })
66
+ })
67
+
68
+ describe('hasPendingCapper', () => {
69
+ test('false with no schedule', () => {
70
+ expect(hasPendingCapper(entity([]))).toBe(false)
71
+ })
72
+
73
+ test('false with only non-capper tasks queued', () => {
74
+ expect(hasPendingCapper(entity([task({type: TaskType.TRAVEL})]))).toBe(false)
75
+ })
76
+
77
+ test('true when a DEMOLISH task is queued anywhere in the lane', () => {
78
+ const e = entity([task({type: TaskType.TRAVEL}), task({type: TaskType.DEMOLISH})])
79
+ expect(hasPendingCapper(e)).toBe(true)
80
+ })
81
+
82
+ test('true when a DEMOLISH task is queued', () => {
83
+ expect(hasPendingCapper(entity([task({type: TaskType.DEMOLISH})]))).toBe(true)
84
+ })
85
+ })
86
+
87
+ describe('hasResolvable — capper gating', () => {
88
+ test('a completed non-capper front is resolvable', () => {
89
+ const e = entity([task({type: TaskType.TRAVEL, duration: 30})])
90
+ expect(hasResolvable(e, NOW)).toBe(true)
91
+ })
92
+
93
+ test('a completed capper front with no holds is resolvable', () => {
94
+ const e = entity([task({type: TaskType.DEMOLISH, duration: 30})])
95
+ expect(hasResolvable(e, NOW)).toBe(true)
96
+ })
97
+
98
+ test('a completed capper front with a live hold is gated', () => {
99
+ const e = entity([task({type: TaskType.DEMOLISH, duration: 30})], [hold(2)])
100
+ expect(hasResolvable(e, NOW)).toBe(false)
101
+ })
102
+
103
+ test('a capper front clears once holds empty', () => {
104
+ const withHold = entity([task({type: TaskType.DEMOLISH, duration: 30})], [hold(2)])
105
+ const cleared = entity([task({type: TaskType.DEMOLISH, duration: 30})], [])
106
+ expect(hasResolvable(withHold, NOW)).toBe(false)
107
+ expect(hasResolvable(cleared, NOW)).toBe(true)
108
+ })
109
+
110
+ test('a live hold does not gate a non-capper front', () => {
111
+ const e = entity([task({type: TaskType.TRAVEL, duration: 30})], [hold(2)])
112
+ expect(hasResolvable(e, NOW)).toBe(true)
113
+ })
114
+
115
+ test('an in-progress front is not resolvable regardless of holds', () => {
116
+ const e = entity([task({type: TaskType.DEMOLISH, duration: 3600})])
117
+ expect(hasResolvable(e, NOW)).toBe(false)
118
+ })
119
+ })
@@ -1,193 +1,333 @@
1
1
  import type {ServerContract} from '../contracts'
2
2
  import {TaskType} from '../types'
3
+ import * as core from './lane-core'
3
4
 
4
5
  type Schedule = ServerContract.Types.schedule
5
6
  type Task = ServerContract.Types.task
7
+ type Lane = ServerContract.Types.lane
8
+ type Hold = ServerContract.Types.hold
9
+
10
+ export const LANE_MOBILITY = 0
11
+ export const LANE_BARRIER = 255
6
12
 
7
13
  export interface ScheduleData {
8
- schedule?: Schedule
9
- }
10
-
11
- export interface Scheduleable extends ScheduleData {
12
- hasSchedule: boolean
13
- isIdle: boolean
14
- tasks: Task[]
15
- scheduleDuration(): number
16
- scheduleElapsed(now: Date): number
17
- scheduleRemaining(now: Date): number
18
- scheduleComplete(now: Date): boolean
19
- currentTaskIndex(now: Date): number
20
- currentTask(now: Date): Task | undefined
21
- currentTaskType(now: Date): TaskType | undefined
22
- getTaskStartTime(index: number): number
23
- getTaskElapsed(index: number, now: Date): number
24
- getTaskRemaining(index: number, now: Date): number
25
- isTaskComplete(index: number, now: Date): boolean
26
- isTaskInProgress(index: number, now: Date): boolean
27
- currentTaskProgress(now: Date): number
28
- scheduleProgress(now: Date): number
14
+ lanes?: Lane[]
15
+ holds?: Hold[]
16
+ }
17
+
18
+ export interface LaneView {
19
+ laneKey: number
20
+ schedule: Schedule
21
+ }
22
+
23
+ export {
24
+ laneStartsIn,
25
+ currentTaskIndexForLane,
26
+ laneTaskComplete,
27
+ laneTaskInProgress,
28
+ laneCompletesAt,
29
+ currentTaskProgressFloatForLane,
30
+ } from './lane-core'
31
+
32
+ export function getLanes(entity: ScheduleData): LaneView[] {
33
+ const lanes = entity.lanes
34
+ if (!lanes || lanes.length === 0) return []
35
+ return lanes.map((l) => ({laneKey: l.lane_key.toNumber(), schedule: l.schedule}))
36
+ }
37
+
38
+ export function getLane(entity: ScheduleData, laneKey: number): LaneView | undefined {
39
+ const lanes = entity.lanes
40
+ if (!lanes) return undefined
41
+ for (const l of lanes) {
42
+ if (l.lane_key.toNumber() === laneKey) return {laneKey, schedule: l.schedule}
43
+ }
44
+ return undefined
45
+ }
46
+
47
+ export function mobilityLane(entity: ScheduleData): LaneView | undefined {
48
+ return getLane(entity, LANE_MOBILITY)
29
49
  }
30
50
 
31
51
  export function hasSchedule(entity: ScheduleData): boolean {
32
- return !!entity.schedule && entity.schedule.tasks.length > 0
52
+ const lanes = entity.lanes
53
+ if (!lanes) return false
54
+ return lanes.some((l) => l.schedule.tasks.length > 0)
55
+ }
56
+
57
+ export function hasHolds(entity: ScheduleData): boolean {
58
+ const holds = entity.holds
59
+ return !!holds && holds.length > 0
33
60
  }
34
61
 
35
62
  export function isIdle(entity: ScheduleData): boolean {
36
- return !hasSchedule(entity)
63
+ return !hasSchedule(entity) && !hasHolds(entity)
64
+ }
65
+
66
+ // Mirrors is_capper_task_type: demolish/undeploy cap a plan — no further appends once queued.
67
+ export function isCapperTaskType(taskType: number): boolean {
68
+ return taskType === TaskType.UNDEPLOY || taskType === TaskType.DEMOLISH
69
+ }
70
+
71
+ export function hasPendingCapper(entity: ScheduleData): boolean {
72
+ for (const l of entity.lanes ?? []) {
73
+ for (const t of l.schedule.tasks) {
74
+ if (isCapperTaskType(t.type.toNumber())) return true
75
+ }
76
+ }
77
+ return false
78
+ }
79
+
80
+ export function isEntityIdle(entity: ScheduleData, now: Date): boolean {
81
+ if (hasHolds(entity)) return false
82
+ const lanes = entity.lanes
83
+ if (!lanes) return true
84
+ return lanes.every((l) => core.currentTaskIndexForLane(l.schedule, now) < 0)
85
+ }
86
+
87
+ export function entityIdleAt(entity: ScheduleData, _now: Date): Date | undefined {
88
+ const lanes = entity.lanes
89
+ if (!lanes) return undefined
90
+ let maxMs: number | undefined
91
+ for (const l of lanes) {
92
+ if (l.schedule.tasks.length === 0) continue
93
+ const endMs = l.schedule.started.toDate().getTime() + core.laneDuration(l.schedule) * 1000
94
+ if (maxMs === undefined || endMs > maxMs) maxMs = endMs
95
+ }
96
+ return maxMs === undefined ? undefined : new Date(maxMs)
37
97
  }
38
98
 
39
99
  export function getTasks(entity: ScheduleData): Task[] {
40
- return entity.schedule?.tasks || []
100
+ const lanes = entity.lanes
101
+ if (!lanes) return []
102
+ return lanes.flatMap((l) => l.schedule.tasks)
41
103
  }
42
104
 
43
105
  export function scheduleDuration(entity: ScheduleData): number {
44
- if (!entity.schedule) return 0
45
- return entity.schedule.tasks.reduce((sum, task) => sum + task.duration.toNumber(), 0)
106
+ let max = 0
107
+ for (const l of entity.lanes ?? []) max = Math.max(max, core.laneDuration(l.schedule))
108
+ return max
46
109
  }
47
110
 
48
111
  export function scheduleElapsed(entity: ScheduleData, now: Date): number {
49
- if (!entity.schedule) return 0
50
- const started = entity.schedule.started.toDate()
51
- const elapsed = Math.floor((now.getTime() - started.getTime()) / 1000)
52
- return Math.max(0, elapsed)
112
+ let max = 0
113
+ for (const l of entity.lanes ?? []) max = Math.max(max, core.laneElapsed(l.schedule, now))
114
+ return max
53
115
  }
54
116
 
55
117
  export function scheduleRemaining(entity: ScheduleData, now: Date): number {
56
- if (!entity.schedule) return 0
57
- const duration = scheduleDuration(entity)
58
- const elapsed = scheduleElapsed(entity, now)
59
- return Math.max(0, duration - elapsed)
118
+ let remaining = 0
119
+ for (const l of entity.lanes ?? []) {
120
+ remaining = Math.max(remaining, core.laneRemaining(l.schedule, now))
121
+ }
122
+ return remaining
60
123
  }
61
124
 
62
125
  export function scheduleComplete(entity: ScheduleData, now: Date): boolean {
63
- return hasSchedule(entity) && scheduleRemaining(entity, now) === 0
126
+ const lanes = entity.lanes
127
+ if (!lanes) return false
128
+ let hasAnyTask = false
129
+ let remaining = 0
130
+ for (const l of lanes) {
131
+ if (l.schedule.tasks.length > 0) hasAnyTask = true
132
+ remaining = Math.max(remaining, core.laneRemaining(l.schedule, now))
133
+ }
134
+ if (!hasAnyTask) return false
135
+ return remaining === 0
64
136
  }
65
137
 
66
- export function currentTaskIndex(entity: ScheduleData, now: Date): number {
67
- if (!entity.schedule || entity.schedule.tasks.length === 0) return -1
68
-
69
- const elapsed = scheduleElapsed(entity, now)
70
- let timeAccum = 0
71
-
72
- for (let i = 0; i < entity.schedule.tasks.length; i++) {
73
- const taskDuration = entity.schedule.tasks[i].duration.toNumber()
74
- if (elapsed < timeAccum + taskDuration) {
75
- return i
76
- }
77
- timeAccum += taskDuration
138
+ // Mirrors lane_front_complete && !capper_front_gated for own-entity holds (workshop/undeploy-target gates need cross-entity context this ScheduleData lacks).
139
+ export function hasResolvable(entity: ScheduleData, now: Date): boolean {
140
+ for (const l of entity.lanes ?? []) {
141
+ if (!core.laneTaskComplete(l.schedule, 0, now)) continue
142
+ const front = l.schedule.tasks[0]
143
+ if (isCapperTaskType(front.type.toNumber()) && hasHolds(entity)) continue
144
+ return true
78
145
  }
146
+ return false
147
+ }
148
+
149
+ export function currentTaskForLane(
150
+ entity: ScheduleData,
151
+ laneKey: number,
152
+ now: Date
153
+ ): Task | undefined {
154
+ const lane = getLane(entity, laneKey)
155
+ return lane ? core.currentTask(lane.schedule, now) : undefined
156
+ }
79
157
 
80
- return -1
158
+ export function currentTaskTypeForLane(
159
+ entity: ScheduleData,
160
+ laneKey: number,
161
+ now: Date
162
+ ): TaskType | undefined {
163
+ const lane = getLane(entity, laneKey)
164
+ return lane ? core.currentTaskType(lane.schedule, now) : undefined
81
165
  }
82
166
 
83
- export function currentTask(entity: ScheduleData, now: Date): Task | undefined {
84
- const index = currentTaskIndex(entity, now)
85
- if (index < 0 || !entity.schedule) return undefined
86
- return entity.schedule.tasks[index]
167
+ export function activeTasks(entity: ScheduleData, now: Date): Task[] {
168
+ const out: Task[] = []
169
+ for (const l of entity.lanes ?? []) {
170
+ const idx = core.currentTaskIndexForLane(l.schedule, now)
171
+ if (idx >= 0) out.push(l.schedule.tasks[idx])
172
+ }
173
+ return out
87
174
  }
88
175
 
89
- export function currentTaskType(entity: ScheduleData, now: Date): TaskType | undefined {
90
- const task = currentTask(entity, now)
91
- if (!task) return undefined
92
- return task.type.toNumber() as TaskType
176
+ export interface ResolvedEvent {
177
+ laneKey: number
178
+ taskIndex: number
179
+ task: Task
180
+ completesAt: Date
93
181
  }
94
182
 
95
- export function getTaskStartTime(entity: ScheduleData, index: number): number {
96
- if (!entity.schedule || index < 0 || index >= entity.schedule.tasks.length) return 0
97
- let timeAccum = 0
98
- for (let i = 0; i < index; i++) {
99
- timeAccum += entity.schedule.tasks[i].duration.toNumber()
183
+ // Canonical lane-front order (mirrors contract front_precedes): completion, then RECHARGE-last, then lane key.
184
+ function frontPrecedes(
185
+ a: {completesAt: Date; task: Task; laneKey: number},
186
+ b: {completesAt: Date; task: Task; laneKey: number}
187
+ ): number {
188
+ if (a.completesAt.getTime() !== b.completesAt.getTime()) {
189
+ return a.completesAt.getTime() - b.completesAt.getTime()
100
190
  }
101
- return timeAccum
191
+ const aRecharge = a.task.type.toNumber() === TaskType.RECHARGE
192
+ const bRecharge = b.task.type.toNumber() === TaskType.RECHARGE
193
+ if (aRecharge !== bRecharge) return aRecharge ? 1 : -1
194
+ return a.laneKey - b.laneKey
102
195
  }
103
196
 
104
- export function getTaskElapsed(entity: ScheduleData, index: number, now: Date): number {
105
- if (!entity.schedule || index < 0 || index >= entity.schedule.tasks.length) return 0
197
+ // Completed lane-fronts in canonical order (mirrors contract front_precedes).
198
+ export function resolveOrder(entity: ScheduleData, now: Date): ResolvedEvent[] {
199
+ const events: ResolvedEvent[] = []
200
+ for (const l of entity.lanes ?? []) {
201
+ const laneKey = l.lane_key.toNumber()
202
+ const startedMs = l.schedule.started.toDate().getTime()
203
+ let endSec = 0
204
+ for (let i = 0; i < l.schedule.tasks.length; i++) {
205
+ const task = l.schedule.tasks[i]
206
+ endSec += task.duration.toNumber()
207
+ const completesAt = new Date(startedMs + endSec * 1000)
208
+ if (completesAt.getTime() > now.getTime()) break
209
+ events.push({laneKey, taskIndex: i, task, completesAt})
210
+ }
211
+ }
212
+ events.sort(frontPrecedes)
213
+ return events
214
+ }
106
215
 
107
- const elapsed = scheduleElapsed(entity, now)
108
- const taskStart = getTaskStartTime(entity, index)
109
- const taskDuration = entity.schedule.tasks[index].duration.toNumber()
216
+ export interface OrderedTask {
217
+ laneKey: number
218
+ taskIndex: number
219
+ task: Task
220
+ startsAt: Date
221
+ completesAt: Date
222
+ }
110
223
 
111
- if (elapsed <= taskStart) return 0
112
- const elapsedInTask = elapsed - taskStart
113
- return Math.min(elapsedInTask, taskDuration)
224
+ // Every task across all lanes in canonical order (mirrors contract front_precedes).
225
+ export function orderedTasks(entity: ScheduleData): OrderedTask[] {
226
+ const out: OrderedTask[] = []
227
+ for (const l of entity.lanes ?? []) {
228
+ const laneKey = l.lane_key.toNumber()
229
+ const startedMs = l.schedule.started.toDate().getTime()
230
+ let endSec = 0
231
+ for (let i = 0; i < l.schedule.tasks.length; i++) {
232
+ const task = l.schedule.tasks[i]
233
+ const startsAt = new Date(startedMs + endSec * 1000)
234
+ endSec += task.duration.toNumber()
235
+ const completesAt = new Date(startedMs + endSec * 1000)
236
+ out.push({laneKey, taskIndex: i, task, startsAt, completesAt})
237
+ }
238
+ }
239
+ out.sort(frontPrecedes)
240
+ return out
114
241
  }
115
242
 
116
- export function getTaskRemaining(entity: ScheduleData, index: number, now: Date): number {
117
- if (!entity.schedule || index < 0 || index >= entity.schedule.tasks.length) return 0
243
+ export function laneRemainingOf(entity: ScheduleData, laneKey: number, now: Date): number {
244
+ const lane = getLane(entity, laneKey)
245
+ return lane ? core.laneRemaining(lane.schedule, now) : 0
246
+ }
118
247
 
119
- const taskDuration = entity.schedule.tasks[index].duration.toNumber()
120
- const taskElapsed = getTaskElapsed(entity, index, now)
121
- return Math.max(0, taskDuration - taskElapsed)
248
+ export function laneStartsInOf(entity: ScheduleData, laneKey: number, now: Date): number {
249
+ const lane = getLane(entity, laneKey)
250
+ return lane ? core.laneStartsIn(lane.schedule, now) : 0
122
251
  }
123
252
 
124
- export function isTaskComplete(entity: ScheduleData, index: number, now: Date): boolean {
125
- if (!entity.schedule || index < 0 || index >= entity.schedule.tasks.length) return false
253
+ export function laneCompleteOf(entity: ScheduleData, laneKey: number, now: Date): boolean {
254
+ const lane = getLane(entity, laneKey)
255
+ return lane ? core.laneComplete(lane.schedule, now) : false
256
+ }
126
257
 
127
- const taskDuration = entity.schedule.tasks[index].duration.toNumber()
128
- const taskElapsed = getTaskElapsed(entity, index, now)
129
- return taskElapsed >= taskDuration
258
+ export function laneProgressOf(entity: ScheduleData, laneKey: number, now: Date): number {
259
+ const lane = getLane(entity, laneKey)
260
+ return lane ? core.laneProgress(lane.schedule, now) : 0
130
261
  }
131
262
 
132
- export function isTaskInProgress(entity: ScheduleData, index: number, now: Date): boolean {
133
- if (!entity.schedule || index < 0 || index >= entity.schedule.tasks.length) return false
263
+ export function laneTaskElapsedOf(
264
+ entity: ScheduleData,
265
+ laneKey: number,
266
+ index: number,
267
+ now: Date
268
+ ): number {
269
+ const lane = getLane(entity, laneKey)
270
+ return lane ? core.laneTaskElapsed(lane.schedule, index, now) : 0
271
+ }
134
272
 
135
- const taskElapsed = getTaskElapsed(entity, index, now)
136
- const taskDuration = entity.schedule.tasks[index].duration.toNumber()
137
- return taskElapsed > 0 && taskElapsed < taskDuration
273
+ export function laneTaskRemainingOf(
274
+ entity: ScheduleData,
275
+ laneKey: number,
276
+ index: number,
277
+ now: Date
278
+ ): number {
279
+ const lane = getLane(entity, laneKey)
280
+ return lane ? core.laneTaskRemaining(lane.schedule, index, now) : 0
138
281
  }
139
282
 
140
- export function currentTaskProgress(entity: ScheduleData, now: Date): number {
141
- const task = currentTask(entity, now)
142
- if (!task) return 0
143
- const index = currentTaskIndex(entity, now)
144
- const elapsed = getTaskElapsed(entity, index, now)
145
- const duration = task.duration.toNumber()
146
- if (duration === 0) return 1
147
- return Math.min(1, elapsed / duration)
283
+ export function laneTaskCompleteOf(
284
+ entity: ScheduleData,
285
+ laneKey: number,
286
+ index: number,
287
+ now: Date
288
+ ): boolean {
289
+ const lane = getLane(entity, laneKey)
290
+ return lane ? core.laneTaskComplete(lane.schedule, index, now) : false
148
291
  }
149
292
 
150
- export function currentTaskProgressFloat(entity: ScheduleData, now: Date): number {
151
- if (!entity.schedule || entity.schedule.tasks.length === 0) return 0
152
- const index = currentTaskIndex(entity, now)
153
- if (index < 0) return 0
154
- const task = entity.schedule.tasks[index]
155
- const durationMs = task.duration.toNumber() * 1000
156
- if (durationMs === 0) return 1
157
- const startedMs = entity.schedule.started.toDate().getTime()
158
- const taskStartMs = startedMs + getTaskStartTime(entity, index) * 1000
159
- const elapsedMs = now.getTime() - taskStartMs
160
- if (elapsedMs <= 0) return 0
161
- return Math.min(1, elapsedMs / durationMs)
293
+ export function laneTaskInProgressOf(
294
+ entity: ScheduleData,
295
+ laneKey: number,
296
+ index: number,
297
+ now: Date
298
+ ): boolean {
299
+ const lane = getLane(entity, laneKey)
300
+ return lane ? core.laneTaskInProgress(lane.schedule, index, now) : false
162
301
  }
163
302
 
164
- export function scheduleProgress(entity: ScheduleData, now: Date): number {
165
- const duration = scheduleDuration(entity)
166
- if (duration === 0) return hasSchedule(entity) ? 1 : 0
167
- const elapsed = scheduleElapsed(entity, now)
168
- return Math.min(1, elapsed / duration)
303
+ export function currentTaskIndexOf(entity: ScheduleData, laneKey: number, now: Date): number {
304
+ const lane = getLane(entity, laneKey)
305
+ return lane ? core.currentTaskIndexForLane(lane.schedule, now) : -1
169
306
  }
170
307
 
171
- export function isTaskType(entity: ScheduleData, taskType: TaskType, now: Date): boolean {
172
- return currentTaskType(entity, now) === taskType
308
+ function entityDoesTaskType(entity: ScheduleData, taskType: TaskType, now: Date): boolean {
309
+ return activeTasks(entity, now).some((t) => t.type.toNumber() === taskType)
173
310
  }
174
311
 
175
312
  export function isInFlight(entity: ScheduleData, now: Date): boolean {
176
- return isTaskType(entity, TaskType.TRAVEL, now)
313
+ const lane = mobilityLane(entity)
314
+ if (!lane) return false
315
+ const t = core.currentTaskType(lane.schedule, now)
316
+ return t === TaskType.TRAVEL || t === TaskType.TRANSIT
177
317
  }
178
318
 
179
319
  export function isRecharging(entity: ScheduleData, now: Date): boolean {
180
- return isTaskType(entity, TaskType.RECHARGE, now)
320
+ return entityDoesTaskType(entity, TaskType.RECHARGE, now)
181
321
  }
182
322
 
183
323
  export function isLoading(entity: ScheduleData, now: Date): boolean {
184
- return isTaskType(entity, TaskType.LOAD, now)
324
+ return entityDoesTaskType(entity, TaskType.LOAD, now)
185
325
  }
186
326
 
187
327
  export function isUnloading(entity: ScheduleData, now: Date): boolean {
188
- return isTaskType(entity, TaskType.UNLOAD, now)
328
+ return entityDoesTaskType(entity, TaskType.UNLOAD, now)
189
329
  }
190
330
 
191
331
  export function isGathering(entity: ScheduleData, now: Date): boolean {
192
- return isTaskType(entity, TaskType.GATHER, now)
332
+ return entityDoesTaskType(entity, TaskType.GATHER, now)
193
333
  }
@@ -0,0 +1,44 @@
1
+ import {describe, expect, test} from 'bun:test'
2
+ import {ServerContract, TaskType} from '../index-module'
3
+ import {taskCargoChanges} from './task-cargo'
4
+
5
+ function cargoItem(itemId: number, quantity: number) {
6
+ return ServerContract.Types.cargo_item.from({item_id: itemId, stats: 0, modules: [], quantity})
7
+ }
8
+
9
+ function craftTask(couplings: ReturnType<typeof coupling>[]) {
10
+ return ServerContract.Types.task.from({
11
+ type: TaskType.CRAFT,
12
+ duration: 60,
13
+ cancelable: 0,
14
+ cargo: [cargoItem(401, 19680), cargoItem(10008, 1968)],
15
+ couplings,
16
+ })
17
+ }
18
+
19
+ function coupling() {
20
+ return ServerContract.Types.coupling.from({
21
+ counterpart: {entity_type: 'warehouse', entity_id: 51},
22
+ hold: 1,
23
+ kind: 3,
24
+ })
25
+ }
26
+
27
+ describe('taskCargoChanges', () => {
28
+ test('a self-craft consumes inputs and delivers the output to the crafter', () => {
29
+ const changes = taskCargoChanges(craftTask([]))
30
+
31
+ expect(changes).toEqual([
32
+ {direction: 'out', item_id: 401, stats: 0n, modules: [], quantity: 19680},
33
+ {direction: 'in', item_id: 10008, stats: 0n, modules: [], quantity: 1968},
34
+ ])
35
+ })
36
+
37
+ test('a coupled craft consumes inputs but does not deliver the output to the crafter', () => {
38
+ const changes = taskCargoChanges(craftTask([coupling()]))
39
+
40
+ expect(changes).toEqual([
41
+ {direction: 'out', item_id: 401, stats: 0n, modules: [], quantity: 19680},
42
+ ])
43
+ })
44
+ })
@@ -0,0 +1,46 @@
1
+ import type {ServerContract} from '../contracts'
2
+ import {TaskType} from '../types'
3
+
4
+ export type TaskCargoDirection = 'in' | 'out'
5
+
6
+ export interface TaskCargoChange {
7
+ direction: TaskCargoDirection
8
+ item_id: number
9
+ stats: bigint
10
+ modules: ServerContract.Types.module_entry[]
11
+ quantity: number
12
+ }
13
+
14
+ function toChange(
15
+ item: ServerContract.Types.cargo_item,
16
+ direction: TaskCargoDirection
17
+ ): TaskCargoChange {
18
+ return {
19
+ direction,
20
+ item_id: Number(item.item_id),
21
+ stats: BigInt(item.stats.toString()),
22
+ modules: item.modules ?? [],
23
+ quantity: Number(item.quantity),
24
+ }
25
+ }
26
+
27
+ export function taskCargoChanges(task: ServerContract.Types.task): TaskCargoChange[] {
28
+ const items = task.cargo ?? []
29
+ if (items.length === 0) return []
30
+ switch (Number(task.type)) {
31
+ case TaskType.LOAD:
32
+ case TaskType.UNWRAP:
33
+ return items.map((i) => toChange(i, 'in'))
34
+ case TaskType.GATHER:
35
+ return task.couplings.length > 0 ? [] : items.map((i) => toChange(i, 'in'))
36
+ case TaskType.UNLOAD:
37
+ return items.map((i) => toChange(i, 'out'))
38
+ case TaskType.CRAFT:
39
+ return [
40
+ ...items.slice(0, -1).map((i) => toChange(i, 'out')),
41
+ ...(task.couplings.length > 0 ? [] : [toChange(items[items.length - 1], 'in')]),
42
+ ]
43
+ default:
44
+ return []
45
+ }
46
+ }