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

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 (127) hide show
  1. package/lib/shipload.d.ts +2473 -973
  2. package/lib/shipload.js +11529 -5211
  3. package/lib/shipload.js.map +1 -1
  4. package/lib/shipload.m.js +11338 -5162
  5. package/lib/shipload.m.js.map +1 -1
  6. package/lib/testing.d.ts +970 -0
  7. package/lib/testing.js +4013 -0
  8. package/lib/testing.js.map +1 -0
  9. package/lib/testing.m.js +4007 -0
  10. package/lib/testing.m.js.map +1 -0
  11. package/package.json +15 -2
  12. package/src/capabilities/craftable.ts +51 -0
  13. package/src/capabilities/crafting.test.ts +7 -0
  14. package/src/capabilities/crafting.ts +5 -6
  15. package/src/capabilities/gathering.test.ts +16 -0
  16. package/src/capabilities/gathering.ts +35 -18
  17. package/src/capabilities/index.ts +0 -1
  18. package/src/capabilities/modules.ts +9 -0
  19. package/src/capabilities/storage.ts +16 -1
  20. package/src/contracts/platform.ts +231 -3
  21. package/src/contracts/server.ts +1021 -481
  22. package/src/coordinates/address.ts +88 -0
  23. package/src/coordinates/constants.test.ts +15 -0
  24. package/src/coordinates/constants.ts +23 -0
  25. package/src/coordinates/index.ts +15 -0
  26. package/src/coordinates/memo.test.ts +47 -0
  27. package/src/coordinates/memo.ts +20 -0
  28. package/src/coordinates/permutation.ts +77 -0
  29. package/src/coordinates/regions.ts +48 -0
  30. package/src/coordinates/sectors.ts +115 -0
  31. package/src/data/capabilities.ts +12 -5
  32. package/src/data/capability-formulas.ts +14 -7
  33. package/src/data/catalog.ts +0 -5
  34. package/src/data/colors.ts +14 -47
  35. package/src/data/entities.json +76 -10
  36. package/src/data/item-ids.ts +18 -12
  37. package/src/data/items.json +321 -38
  38. package/src/data/kind-registry.json +109 -0
  39. package/src/data/kind-registry.ts +165 -0
  40. package/src/data/metadata.ts +119 -33
  41. package/src/data/recipes-runtime.ts +3 -23
  42. package/src/data/recipes.json +238 -117
  43. package/src/derivation/build-methods.ts +45 -0
  44. package/src/derivation/capabilities.test.ts +151 -0
  45. package/src/derivation/capabilities.ts +512 -0
  46. package/src/derivation/capability-mappings.ts +9 -12
  47. package/src/derivation/crafting.ts +23 -24
  48. package/src/derivation/index.ts +25 -2
  49. package/src/derivation/recipe-usage.test.ts +78 -0
  50. package/src/derivation/recipe-usage.ts +141 -0
  51. package/src/derivation/reserve-regen.ts +34 -0
  52. package/src/derivation/resources.ts +125 -38
  53. package/src/derivation/rollups.test.ts +55 -0
  54. package/src/derivation/rollups.ts +56 -0
  55. package/src/derivation/stars.test.ts +51 -0
  56. package/src/derivation/stars.ts +15 -0
  57. package/src/derivation/stats.ts +6 -6
  58. package/src/derivation/stratum.ts +17 -20
  59. package/src/derivation/tiers.ts +40 -7
  60. package/src/derivation/wormhole.ts +136 -0
  61. package/src/entities/entity.ts +98 -0
  62. package/src/entities/gamestate.ts +3 -28
  63. package/src/entities/makers.ts +124 -134
  64. package/src/entities/slot-multiplier.ts +43 -0
  65. package/src/errors.ts +12 -16
  66. package/src/format.ts +26 -4
  67. package/src/index-module.ts +267 -47
  68. package/src/managers/actions.ts +528 -95
  69. package/src/managers/base.ts +6 -2
  70. package/src/managers/construction-types.ts +80 -0
  71. package/src/managers/construction.ts +412 -0
  72. package/src/managers/context.ts +20 -1
  73. package/src/managers/coordinates.ts +14 -0
  74. package/src/managers/entities.ts +18 -66
  75. package/src/managers/epochs.ts +40 -0
  76. package/src/managers/index.ts +17 -1
  77. package/src/managers/locations.ts +25 -29
  78. package/src/managers/nft.test.ts +14 -0
  79. package/src/managers/nft.ts +70 -0
  80. package/src/managers/plot.ts +122 -0
  81. package/src/nft/atomicassets.abi.json +1342 -0
  82. package/src/nft/atomicassets.ts +237 -0
  83. package/src/nft/atomicdata.ts +130 -0
  84. package/src/nft/buildImmutableData.ts +338 -0
  85. package/src/nft/description.ts +98 -24
  86. package/src/nft/index.ts +3 -0
  87. package/src/planner/index.ts +127 -0
  88. package/src/planner/planner.test.ts +319 -0
  89. package/src/resolution/describe-module.ts +18 -13
  90. package/src/resolution/display-name.ts +38 -10
  91. package/src/resolution/resolve-item.test.ts +37 -0
  92. package/src/resolution/resolve-item.ts +55 -24
  93. package/src/scheduling/accessor.ts +68 -22
  94. package/src/scheduling/availability.ts +108 -0
  95. package/src/scheduling/cancel.test.ts +348 -0
  96. package/src/scheduling/cancel.ts +209 -0
  97. package/src/scheduling/energy.ts +47 -0
  98. package/src/scheduling/idle-resolve.ts +45 -0
  99. package/src/scheduling/lane-core.ts +128 -0
  100. package/src/scheduling/lanes.test.ts +249 -0
  101. package/src/scheduling/lanes.ts +198 -0
  102. package/src/scheduling/projection.ts +209 -105
  103. package/src/scheduling/schedule.ts +241 -104
  104. package/src/scheduling/task-cargo.ts +46 -0
  105. package/src/shipload.ts +21 -1
  106. package/src/subscriptions/manager.ts +229 -142
  107. package/src/subscriptions/mappers.ts +5 -8
  108. package/src/subscriptions/types.ts +11 -3
  109. package/src/testing/catalog-hash.ts +19 -0
  110. package/src/testing/index.ts +2 -0
  111. package/src/testing/projection-parity.ts +167 -0
  112. package/src/travel/reach.ts +23 -0
  113. package/src/travel/route-planner.ts +196 -0
  114. package/src/travel/travel.ts +200 -112
  115. package/src/types/capabilities.ts +29 -6
  116. package/src/types/entity.ts +3 -3
  117. package/src/types/index.ts +0 -1
  118. package/src/types.ts +28 -13
  119. package/src/utils/cargo.ts +27 -0
  120. package/src/utils/display-name.ts +70 -0
  121. package/src/utils/system.ts +36 -24
  122. package/src/capabilities/loading.ts +0 -8
  123. package/src/entities/container.ts +0 -108
  124. package/src/entities/ship-deploy.ts +0 -259
  125. package/src/entities/ship.ts +0 -204
  126. package/src/entities/warehouse.ts +0 -119
  127. package/src/types/entity-traits.ts +0 -69
@@ -0,0 +1,128 @@
1
+ import type {ServerContract} from '../contracts'
2
+ import type {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
+ return laneRemaining(schedule, now) === 0
31
+ }
32
+
33
+ export function laneProgress(schedule: Schedule, now: Date): number {
34
+ const duration = laneDuration(schedule)
35
+ if (duration === 0) return schedule.tasks.length > 0 ? 1 : 0
36
+ return Math.min(1, laneElapsed(schedule, now) / duration)
37
+ }
38
+
39
+ export function currentTaskIndexForLane(schedule: Schedule, now: Date): number {
40
+ if (schedule.tasks.length === 0) return -1
41
+ if (laneRawElapsed(schedule, now) < 0) return -1
42
+ const elapsed = laneElapsed(schedule, now)
43
+ let timeAccum = 0
44
+ for (let i = 0; i < schedule.tasks.length; i++) {
45
+ const taskDuration = schedule.tasks[i].duration.toNumber()
46
+ if (elapsed < timeAccum + taskDuration) return i
47
+ timeAccum += taskDuration
48
+ }
49
+ return -1
50
+ }
51
+
52
+ export function currentTask(schedule: Schedule, now: Date): Task | undefined {
53
+ const index = currentTaskIndexForLane(schedule, now)
54
+ if (index < 0) return undefined
55
+ return schedule.tasks[index]
56
+ }
57
+
58
+ export function currentTaskType(schedule: Schedule, now: Date): TaskType | undefined {
59
+ const task = currentTask(schedule, now)
60
+ return task ? (task.type.toNumber() as TaskType) : undefined
61
+ }
62
+
63
+ export function laneTaskStartTime(schedule: Schedule, index: number): number {
64
+ if (index < 0 || index >= schedule.tasks.length) return 0
65
+ let timeAccum = 0
66
+ for (let i = 0; i < index; i++) {
67
+ timeAccum += schedule.tasks[i].duration.toNumber()
68
+ }
69
+ return timeAccum
70
+ }
71
+
72
+ export function laneTaskElapsed(schedule: Schedule, index: number, now: Date): number {
73
+ if (index < 0 || index >= schedule.tasks.length) return 0
74
+ const elapsed = laneElapsed(schedule, now)
75
+ const taskStart = laneTaskStartTime(schedule, index)
76
+ const taskDuration = schedule.tasks[index].duration.toNumber()
77
+ if (elapsed <= taskStart) return 0
78
+ return Math.min(elapsed - taskStart, taskDuration)
79
+ }
80
+
81
+ export function laneTaskRemaining(schedule: Schedule, index: number, now: Date): number {
82
+ if (index < 0 || index >= schedule.tasks.length) return 0
83
+ const taskDuration = schedule.tasks[index].duration.toNumber()
84
+ return Math.max(0, taskDuration - laneTaskElapsed(schedule, index, now))
85
+ }
86
+
87
+ export function laneTaskComplete(schedule: Schedule, index: number, now: Date): boolean {
88
+ if (index < 0 || index >= schedule.tasks.length) return false
89
+ const taskDuration = schedule.tasks[index].duration.toNumber()
90
+ return laneTaskElapsed(schedule, index, now) >= taskDuration
91
+ }
92
+
93
+ export function laneTaskInProgress(schedule: Schedule, index: number, now: Date): boolean {
94
+ if (index < 0 || index >= schedule.tasks.length) return false
95
+ const taskElapsed = laneTaskElapsed(schedule, index, now)
96
+ const taskDuration = schedule.tasks[index].duration.toNumber()
97
+ return taskElapsed > 0 && taskElapsed < taskDuration
98
+ }
99
+
100
+ export function laneCompletesAt(schedule: Schedule, index: number): Date {
101
+ const startedMs = schedule.started.toDate().getTime()
102
+ const endSec =
103
+ laneTaskStartTime(schedule, index) + (schedule.tasks[index]?.duration.toNumber() ?? 0)
104
+ return new Date(startedMs + endSec * 1000)
105
+ }
106
+
107
+ export function currentTaskProgress(schedule: Schedule, now: Date): number {
108
+ const index = currentTaskIndexForLane(schedule, now)
109
+ if (index < 0) return 0
110
+ const elapsed = laneTaskElapsed(schedule, index, now)
111
+ const duration = schedule.tasks[index].duration.toNumber()
112
+ if (duration === 0) return 1
113
+ return Math.min(1, elapsed / duration)
114
+ }
115
+
116
+ export function currentTaskProgressFloatForLane(schedule: Schedule, now: Date): number {
117
+ if (schedule.tasks.length === 0) return 0
118
+ const index = currentTaskIndexForLane(schedule, now)
119
+ if (index < 0) return 0
120
+ const task = schedule.tasks[index]
121
+ const durationMs = task.duration.toNumber() * 1000
122
+ if (durationMs === 0) return 1
123
+ const startedMs = schedule.started.toDate().getTime()
124
+ const taskStartMs = startedMs + laneTaskStartTime(schedule, index) * 1000
125
+ const elapsedMs = now.getTime() - taskStartMs
126
+ if (elapsedMs <= 0) return 0
127
+ return Math.min(1, elapsedMs / durationMs)
128
+ }
@@ -0,0 +1,249 @@
1
+ import {UInt8, UInt16, UInt64} from '@wharfkit/antelope'
2
+ import {expect, test} from 'bun:test'
3
+ import {encodeStats} from '../derivation/crafting'
4
+ import {computeGathererYield, computeGathererDepth, computeGathererDrain} from '../nft/description'
5
+ import {computeLoaderThrust, computeLoaderMass} from '../nft/description'
6
+ import {computeCrafterSpeed, computeCrafterDrain} from '../nft/description'
7
+ import {applySlotMultiplier} from '../entities/slot-multiplier'
8
+ import {getSlotAmp} from '../entities/slot-multiplier'
9
+ import {getEntityLayout} from '../data/recipes-runtime'
10
+ import {ITEM_GATHERER_T1, ITEM_LOADER_T1, ITEM_CRAFTER_T1} from '../data/item-ids'
11
+ import {
12
+ workerLaneKey,
13
+ resolveLaneGatherer,
14
+ resolveLaneCrafter,
15
+ resolveLaneLoader,
16
+ selectGatherLane,
17
+ } from './lanes'
18
+ import {LANE_MOBILITY} from './schedule'
19
+ import type {ServerContract} from '../contracts'
20
+
21
+ type ModuleEntry = ServerContract.Types.module_entry
22
+ type Lane = ServerContract.Types.lane
23
+
24
+ // Real gatherer-bearing entity: extractor 10203 has [generator@0, gatherer@1].
25
+ const EXTRACTOR_GATHERER = 10203
26
+ const GATHERER_SLOT_IDX = 1
27
+ const GATHERER_LANE_KEY = GATHERER_SLOT_IDX + 1 // = 2
28
+
29
+ function makeModuleEntry(itemId: number, stats: bigint): ModuleEntry {
30
+ return {
31
+ type: UInt8.from(0),
32
+ installed: {
33
+ item_id: UInt16.from(itemId),
34
+ stats: UInt64.from(stats),
35
+ },
36
+ } as unknown as ModuleEntry
37
+ }
38
+
39
+ function makeBusyLane(laneKey: number): Lane {
40
+ return {
41
+ lane_key: UInt8.from(laneKey),
42
+ schedule: {
43
+ started: {toDate: () => new Date()} as any,
44
+ tasks: [{duration: UInt64.from(100)} as any],
45
+ },
46
+ } as unknown as Lane
47
+ }
48
+
49
+ // gatherer: stats = [str=300, tol=200, con=400], tier=1
50
+ const GATH_STR = 300
51
+ const GATH_TOL = 200
52
+ const GATH_CON = 400
53
+ const gathererStats1 = encodeStats([GATH_STR, GATH_TOL, GATH_CON])
54
+
55
+ // loader: stats = [ins=300, pla=500]
56
+ const LOADER_INS = 300
57
+ const LOADER_PLA = 500
58
+ const loaderStats = encodeStats([LOADER_INS, LOADER_PLA])
59
+
60
+ // crafter: stats = [rea=400, fin=300]
61
+ const CRAFTER_REA = 400
62
+ const CRAFTER_FIN = 300
63
+ const crafterStats = encodeStats([CRAFTER_REA, CRAFTER_FIN])
64
+
65
+ // --- resolveLaneGatherer ---
66
+
67
+ test('resolveLaneGatherer reads the layout slot amp and applies it to yield (parity formula)', () => {
68
+ // Gatherer at the entity's real gatherer slot (index 1 => laneKey 2); amp comes from getEntityLayout(10203).
69
+ const modules: ModuleEntry[] = [
70
+ makeModuleEntry(ITEM_GATHERER_T1, gathererStats1), // slot 0 (generator slot, ignored)
71
+ makeModuleEntry(ITEM_GATHERER_T1, gathererStats1), // slot 1 (gatherer slot)
72
+ ]
73
+ const result = resolveLaneGatherer(modules, EXTRACTOR_GATHERER, GATHERER_LANE_KEY)
74
+
75
+ const layout = getEntityLayout(EXTRACTOR_GATHERER)?.slots ?? []
76
+ const ampFromLayout = getSlotAmp(layout, GATHERER_SLOT_IDX)
77
+
78
+ // The resolver routes through the real layout's amp, not a hardcoded 100.
79
+ expect(result.outputPct).toBe(ampFromLayout)
80
+ // Yield equals the contract formula clamp_to_uint16(compute_gatherer_yield(str) * amp / 100).
81
+ expect(result.yield).toBe(applySlotMultiplier(computeGathererYield(GATH_STR), ampFromLayout))
82
+ expect(result.drain).toBe(computeGathererDrain(GATH_CON))
83
+ expect(result.depth).toBe(computeGathererDepth(GATH_TOL, 1))
84
+ expect(result.slotIndex).toBe(GATHERER_SLOT_IDX)
85
+ })
86
+
87
+ test('resolveLaneGatherer amp-scaling parity holds for a non-100 amp', () => {
88
+ // Parity for a non-100 amp: clamp_to_uint16(value * amp / 100).
89
+ expect(applySlotMultiplier(computeGathererYield(GATH_STR), 80)).toBe(
90
+ Math.min(Math.floor((computeGathererYield(GATH_STR) * 80) / 100), 65535)
91
+ )
92
+ expect(applySlotMultiplier(computeGathererYield(GATH_STR), 120)).toBe(
93
+ Math.floor((computeGathererYield(GATH_STR) * 120) / 100)
94
+ )
95
+ })
96
+
97
+ test('resolveLaneGatherer throws on out-of-range laneKey', () => {
98
+ const modules: ModuleEntry[] = [makeModuleEntry(ITEM_GATHERER_T1, gathererStats1)]
99
+ expect(() => resolveLaneGatherer(modules, ITEM_GATHERER_T1, 5)).toThrow(
100
+ 'gatherer lane has no module'
101
+ )
102
+ })
103
+
104
+ test('resolveLaneGatherer throws on laneKey=0 (slot=255 off-by-one boundary)', () => {
105
+ const modules: ModuleEntry[] = [makeModuleEntry(ITEM_GATHERER_T1, gathererStats1)]
106
+ expect(() => resolveLaneGatherer(modules, ITEM_GATHERER_T1, 0)).toThrow(
107
+ 'gatherer lane has no module'
108
+ )
109
+ })
110
+
111
+ // --- resolveLaneCrafter ---
112
+
113
+ test('resolveLaneCrafter returns correct stats for slot 0 (laneKey=1)', () => {
114
+ const modules: ModuleEntry[] = [makeModuleEntry(ITEM_CRAFTER_T1, crafterStats)]
115
+ const result = resolveLaneCrafter(modules, ITEM_CRAFTER_T1, 1)
116
+ const layout = getEntityLayout(ITEM_CRAFTER_T1)?.slots ?? []
117
+ const amp = getSlotAmp(layout, 0)
118
+ expect(result.slotIndex).toBe(0)
119
+ expect(result.speed).toBe(applySlotMultiplier(computeCrafterSpeed(CRAFTER_REA), amp))
120
+ expect(result.drain).toBe(computeCrafterDrain(CRAFTER_FIN))
121
+ expect(result.outputPct).toBe(amp)
122
+ })
123
+
124
+ test('resolveLaneCrafter throws on out-of-range laneKey', () => {
125
+ const modules: ModuleEntry[] = [makeModuleEntry(ITEM_CRAFTER_T1, crafterStats)]
126
+ expect(() => resolveLaneCrafter(modules, ITEM_CRAFTER_T1, 5)).toThrow(
127
+ 'crafter lane has no module'
128
+ )
129
+ })
130
+
131
+ test('resolveLaneCrafter throws on laneKey=0 (slot=255 off-by-one boundary)', () => {
132
+ const modules: ModuleEntry[] = [makeModuleEntry(ITEM_CRAFTER_T1, crafterStats)]
133
+ expect(() => resolveLaneCrafter(modules, ITEM_CRAFTER_T1, 0)).toThrow(
134
+ 'crafter lane has no module'
135
+ )
136
+ })
137
+
138
+ // --- resolveLaneLoader ---
139
+
140
+ test('resolveLaneLoader returns correct stats for slot 0 (laneKey=1)', () => {
141
+ const modules: ModuleEntry[] = [makeModuleEntry(ITEM_LOADER_T1, loaderStats)]
142
+ const result = resolveLaneLoader(modules, ITEM_LOADER_T1, 1)
143
+ const layout = getEntityLayout(ITEM_LOADER_T1)?.slots ?? []
144
+ const amp = getSlotAmp(layout, 0)
145
+ expect(result.valid).toBe(true)
146
+ expect(result.slotIndex).toBe(0)
147
+ expect(result.thrust).toBe(applySlotMultiplier(computeLoaderThrust(LOADER_PLA), amp))
148
+ expect(result.mass).toBe(computeLoaderMass(LOADER_INS))
149
+ expect(result.outputPct).toBe(amp)
150
+ })
151
+
152
+ test('resolveLaneLoader soft-returns invalid for LANE_MOBILITY (key 0), never throws', () => {
153
+ const modules: ModuleEntry[] = [makeModuleEntry(ITEM_LOADER_T1, loaderStats)]
154
+ const result = resolveLaneLoader(modules, ITEM_LOADER_T1, LANE_MOBILITY)
155
+ expect(result.valid).toBe(false)
156
+ expect(result.thrust).toBe(0)
157
+ expect(result.mass).toBe(0)
158
+ })
159
+
160
+ test('resolveLaneLoader soft-returns invalid for a missing/out-of-range module, never throws', () => {
161
+ const modules: ModuleEntry[] = [makeModuleEntry(ITEM_LOADER_T1, loaderStats)]
162
+ const result = resolveLaneLoader(modules, ITEM_LOADER_T1, 5)
163
+ expect(result.valid).toBe(false)
164
+ expect(result.thrust).toBe(0)
165
+ expect(result.mass).toBe(0)
166
+ })
167
+
168
+ // --- depth-aware workerLaneKey ---
169
+
170
+ test('workerLaneKey with stratum picks first-free reaching gatherer', () => {
171
+ const shallowStats = encodeStats([100, 200, 100]) // tol=200 => depth=1500 for tier1
172
+ const deepStats = encodeStats([100, 900, 100]) // tol=900 => depth=5000 for tier1
173
+ const modules: ModuleEntry[] = [
174
+ makeModuleEntry(ITEM_GATHERER_T1, shallowStats),
175
+ makeModuleEntry(ITEM_GATHERER_T1, deepStats),
176
+ ]
177
+ const laneKey = workerLaneKey(modules, 'gatherer', [], 2000)
178
+ expect(laneKey).toBe(2) // slot 1 => laneKey 2
179
+ })
180
+
181
+ test('workerLaneKey with stratum: first free reaching is preferred over busy reaching', () => {
182
+ const deepStats = encodeStats([100, 900, 100]) // tol=900 => depth=5000 for tier1
183
+ const modules: ModuleEntry[] = [
184
+ makeModuleEntry(ITEM_GATHERER_T1, deepStats),
185
+ makeModuleEntry(ITEM_GATHERER_T1, deepStats),
186
+ ]
187
+ const lanes: Lane[] = [makeBusyLane(1)] // slot 0 is busy
188
+ const laneKey = workerLaneKey(modules, 'gatherer', lanes, 2000)
189
+ expect(laneKey).toBe(2) // slot 1 (laneKey=2) is free and reaching
190
+ })
191
+
192
+ test('workerLaneKey with stratum: returns lowest reaching (busy) when all reaching are busy', () => {
193
+ const deepStats = encodeStats([100, 900, 100]) // tol=900 => depth=5000 for tier1
194
+ const modules: ModuleEntry[] = [
195
+ makeModuleEntry(ITEM_GATHERER_T1, deepStats),
196
+ makeModuleEntry(ITEM_GATHERER_T1, deepStats),
197
+ ]
198
+ const lanes: Lane[] = [makeBusyLane(1), makeBusyLane(2)]
199
+ const laneKey = workerLaneKey(modules, 'gatherer', lanes, 2000)
200
+ expect(laneKey).toBe(1) // lowest reaching is slot 0 => laneKey 1
201
+ })
202
+
203
+ test('workerLaneKey with stratum throws "no gatherer reaches this stratum" when none reach', () => {
204
+ const shallowStats = encodeStats([100, 10, 100]) // tol=10 => depth=550 for tier1
205
+ const modules: ModuleEntry[] = [makeModuleEntry(ITEM_GATHERER_T1, shallowStats)]
206
+ expect(() => workerLaneKey(modules, 'gatherer', [], 5000)).toThrow(
207
+ 'no gatherer reaches this stratum'
208
+ )
209
+ })
210
+
211
+ test('workerLaneKey without stratum still works (non-gatherer path)', () => {
212
+ const modules: ModuleEntry[] = [makeModuleEntry(ITEM_CRAFTER_T1, crafterStats)]
213
+ const laneKey = workerLaneKey(modules, 'crafter', [])
214
+ expect(laneKey).toBe(1)
215
+ })
216
+
217
+ // --- selectGatherLane: both real error paths ---
218
+
219
+ test('selectGatherLane auto-pick returns the depth-aware lane', () => {
220
+ const shallowStats = encodeStats([100, 200, 100]) // depth=1500 for tier1
221
+ const deepStats = encodeStats([100, 900, 100]) // depth=5000 for tier1
222
+ const modules: ModuleEntry[] = [
223
+ makeModuleEntry(ITEM_GATHERER_T1, shallowStats),
224
+ makeModuleEntry(ITEM_GATHERER_T1, deepStats),
225
+ ]
226
+ expect(selectGatherLane(modules, ITEM_GATHERER_T1, [], 2000)).toBe(2)
227
+ })
228
+
229
+ test('selectGatherLane auto-pick throws "no gatherer reaches this stratum" when none reach', () => {
230
+ const shallowStats = encodeStats([100, 10, 100]) // depth=550 for tier1
231
+ const modules: ModuleEntry[] = [makeModuleEntry(ITEM_GATHERER_T1, shallowStats)]
232
+ expect(() => selectGatherLane(modules, ITEM_GATHERER_T1, [], 5000)).toThrow(
233
+ 'no gatherer reaches this stratum'
234
+ )
235
+ })
236
+
237
+ test('selectGatherLane explicit slot returns its laneKey when stratum is within depth', () => {
238
+ const deepStats = encodeStats([100, 900, 100]) // depth=5000 for tier1
239
+ const modules: ModuleEntry[] = [makeModuleEntry(ITEM_GATHERER_T1, deepStats)]
240
+ expect(selectGatherLane(modules, ITEM_GATHERER_T1, [], 2000, 0)).toBe(1) // slot 0 => laneKey 1
241
+ })
242
+
243
+ test('selectGatherLane explicit slot too shallow throws "stratum exceeds gatherer depth"', () => {
244
+ const shallowStats = encodeStats([100, 10, 100]) // depth=550 for tier1
245
+ const modules: ModuleEntry[] = [makeModuleEntry(ITEM_GATHERER_T1, shallowStats)]
246
+ expect(() => selectGatherLane(modules, ITEM_GATHERER_T1, [], 5000, 0)).toThrow(
247
+ 'stratum exceeds gatherer depth'
248
+ )
249
+ })
@@ -0,0 +1,198 @@
1
+ import type {ServerContract} from '../contracts'
2
+ import {getItem} from '../data/catalog'
3
+ import {getEntityLayout} from '../data/recipes-runtime'
4
+ import {decodeStat} from '../derivation/crafting'
5
+ import {gathererDepthForTier} from '../derivation/capabilities'
6
+ import {applySlotMultiplier, getSlotAmp} from '../entities/slot-multiplier'
7
+ import {
8
+ computeGathererYield,
9
+ computeGathererDrain,
10
+ computeLoaderThrust,
11
+ computeLoaderMass,
12
+ computeCrafterSpeed,
13
+ computeCrafterDrain,
14
+ } from '../nft/description'
15
+ import type {ModuleType} from '../types'
16
+ import {getLane, LANE_MOBILITY, type ScheduleData} from './schedule'
17
+
18
+ type ModuleEntry = ServerContract.Types.module_entry
19
+ type Lane = ServerContract.Types.lane
20
+ type Schedule = ServerContract.Types.schedule
21
+
22
+ export interface ResolvedGathererLane {
23
+ slotIndex: number
24
+ yield: number
25
+ drain: number
26
+ depth: number
27
+ outputPct: number
28
+ }
29
+
30
+ export interface ResolvedCrafterLane {
31
+ slotIndex: number
32
+ speed: number
33
+ drain: number
34
+ outputPct: number
35
+ }
36
+
37
+ export interface ResolvedLoaderLane {
38
+ slotIndex: number
39
+ thrust: number
40
+ mass: number
41
+ outputPct: number
42
+ valid: boolean
43
+ }
44
+
45
+ export function laneKeyForModule(slotIndex: number): number {
46
+ return slotIndex + 1
47
+ }
48
+
49
+ function laneIsFree(lanes: Lane[], laneKey: number): boolean {
50
+ const lane = lanes.find((entry) => entry.lane_key.toNumber() === laneKey)
51
+ return lane ? lane.schedule.tasks.length === 0 : true
52
+ }
53
+
54
+ export function resolveLaneGatherer(
55
+ modules: ModuleEntry[],
56
+ entityItemId: number,
57
+ laneKey: number
58
+ ): ResolvedGathererLane {
59
+ const idx = laneKey - 1
60
+ const installed = idx >= 0 && idx < modules.length ? modules[idx].installed : undefined
61
+ if (!installed) throw new Error('gatherer lane has no module')
62
+ const item = getItem(Number(installed.item_id.value ?? installed.item_id))
63
+ if (item.moduleType !== 'gatherer') throw new Error('lane module is not a gatherer')
64
+ const stats = BigInt(installed.stats.toString())
65
+ const str = decodeStat(stats, 0)
66
+ const tol = decodeStat(stats, 1)
67
+ const con = decodeStat(stats, 2)
68
+ const layout = getEntityLayout(entityItemId)?.slots ?? []
69
+ const amp = getSlotAmp(layout, idx)
70
+ const yieldVal = applySlotMultiplier(computeGathererYield(str), amp)
71
+ const drain = computeGathererDrain(con)
72
+ const depth = gathererDepthForTier(tol, item.tier ?? 1)
73
+ return {slotIndex: idx, yield: yieldVal, drain, depth, outputPct: amp}
74
+ }
75
+
76
+ // Encapsulates the gather handler's lane selection (gathering.cpp:108-112): both error paths.
77
+ export function selectGatherLane(
78
+ modules: ModuleEntry[],
79
+ entityItemId: number,
80
+ lanes: Lane[],
81
+ stratum: number,
82
+ explicitSlot?: number
83
+ ): number {
84
+ if (explicitSlot !== undefined) {
85
+ const laneKey = laneKeyForModule(explicitSlot)
86
+ const lane = resolveLaneGatherer(modules, entityItemId, laneKey)
87
+ if (stratum > lane.depth) throw new Error('stratum exceeds gatherer depth')
88
+ return laneKey
89
+ }
90
+ return workerLaneKey(modules, 'gatherer', lanes, stratum)
91
+ }
92
+
93
+ export function resolveLaneCrafter(
94
+ modules: ModuleEntry[],
95
+ entityItemId: number,
96
+ laneKey: number
97
+ ): ResolvedCrafterLane {
98
+ const idx = laneKey - 1
99
+ const installed = idx >= 0 && idx < modules.length ? modules[idx].installed : undefined
100
+ if (!installed) throw new Error('crafter lane has no module')
101
+ const item = getItem(Number(installed.item_id.value ?? installed.item_id))
102
+ if (item.moduleType !== 'crafter') throw new Error('lane module is not a crafter')
103
+ const stats = BigInt(installed.stats.toString())
104
+ const rea = decodeStat(stats, 0)
105
+ const fin = decodeStat(stats, 1)
106
+ const layout = getEntityLayout(entityItemId)?.slots ?? []
107
+ const amp = getSlotAmp(layout, idx)
108
+ const speed = applySlotMultiplier(computeCrafterSpeed(rea), amp)
109
+ const drain = computeCrafterDrain(fin)
110
+ return {slotIndex: idx, speed, drain, outputPct: amp}
111
+ }
112
+
113
+ // LANE_MOBILITY or a missing module soft-returns valid=false (never throws); callers check `valid`.
114
+ export function resolveLaneLoader(
115
+ modules: ModuleEntry[],
116
+ entityItemId: number,
117
+ laneKey: number
118
+ ): ResolvedLoaderLane {
119
+ if (laneKey === LANE_MOBILITY) {
120
+ return {slotIndex: -1, thrust: 0, mass: 0, outputPct: 0, valid: false}
121
+ }
122
+ const idx = laneKey - 1
123
+ const installed = idx >= 0 && idx < modules.length ? modules[idx].installed : undefined
124
+ if (!installed) {
125
+ return {slotIndex: idx, thrust: 0, mass: 0, outputPct: 0, valid: false}
126
+ }
127
+ const stats = BigInt(installed.stats.toString())
128
+ const ins = decodeStat(stats, 0)
129
+ const pla = decodeStat(stats, 1)
130
+ const layout = getEntityLayout(entityItemId)?.slots ?? []
131
+ const amp = getSlotAmp(layout, idx)
132
+ const thrust = applySlotMultiplier(computeLoaderThrust(pla), amp)
133
+ const mass = computeLoaderMass(ins)
134
+ return {slotIndex: idx, thrust, mass, outputPct: amp, valid: true}
135
+ }
136
+
137
+ export function workerLaneKey(
138
+ modules: ModuleEntry[],
139
+ moduleSubtype: ModuleType,
140
+ lanes: Lane[],
141
+ stratum?: number
142
+ ): number {
143
+ if (moduleSubtype === 'gatherer' && stratum !== undefined) {
144
+ let lowestReaching: number | undefined
145
+ for (let i = 0; i < modules.length; i++) {
146
+ const installed = modules[i].installed
147
+ if (!installed) continue
148
+ const item = getItem(Number(installed.item_id.value ?? installed.item_id))
149
+ if (item.moduleType !== 'gatherer') continue
150
+ const stats = BigInt(installed.stats.toString())
151
+ const tol = decodeStat(stats, 1)
152
+ const depth = gathererDepthForTier(tol, item.tier ?? 1)
153
+ if (depth < stratum) continue
154
+ const laneKey = laneKeyForModule(i)
155
+ if (lowestReaching === undefined) lowestReaching = laneKey
156
+ if (laneIsFree(lanes, laneKey)) return laneKey
157
+ }
158
+ if (lowestReaching === undefined) throw new Error('no gatherer reaches this stratum')
159
+ return lowestReaching
160
+ }
161
+
162
+ const occupiedMatchingLaneKeys: number[] = []
163
+
164
+ for (let slotIndex = 0; slotIndex < modules.length; slotIndex++) {
165
+ const installed = modules[slotIndex].installed
166
+ if (!installed) continue
167
+ if (getItem(installed.item_id).moduleType !== moduleSubtype) continue
168
+
169
+ const laneKey = laneKeyForModule(slotIndex)
170
+ if (laneIsFree(lanes, laneKey)) return laneKey
171
+ occupiedMatchingLaneKeys.push(laneKey)
172
+ }
173
+
174
+ if (occupiedMatchingLaneKeys.length > 0) {
175
+ return Math.min(...occupiedMatchingLaneKeys)
176
+ }
177
+
178
+ throw new Error(`No installed ${moduleSubtype} worker module`)
179
+ }
180
+
181
+ export function rawScheduleEnd(schedule: Schedule): Date {
182
+ const durationSec = schedule.tasks.reduce((sum, task) => sum + task.duration.toNumber(), 0)
183
+ return new Date(schedule.started.toDate().getTime() + durationSec * 1000)
184
+ }
185
+
186
+ export function candidateLaneCompletesAt(
187
+ entity: ScheduleData,
188
+ laneKey: number,
189
+ durationSec: number,
190
+ now: Date
191
+ ): Date {
192
+ const lane = getLane(entity, laneKey)
193
+ const startMs = lane
194
+ ? Math.max(rawScheduleEnd(lane.schedule).getTime(), now.getTime())
195
+ : now.getTime()
196
+
197
+ return new Date(startMs + durationSec * 1000)
198
+ }