@shipload/sdk 1.0.0-next.59 → 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.
@@ -76,10 +76,23 @@ function dist(a: {x: number; y: number}, b: {x: number; y: number}): number {
76
76
  function inBounds(c: {x: number; y: number}): boolean {
77
77
  return c.x >= COORD_MIN && c.x <= COORD_MAX && c.y >= COORD_MIN && c.y <= COORD_MAX
78
78
  }
79
- function wormholeOfRegion(
80
- seed: Checksum256Type,
81
- R: Region
82
- ): {A: {x: number; y: number}; B: {x: number; y: number}} | null {
79
+ type RegionWormhole = {A: {x: number; y: number}; B: {x: number; y: number}} | null
80
+
81
+ // ~11 sha512 rolls per region; pure in (seed, region), so memoized for A* neighbor probes.
82
+ const regionCache = new Map<string, RegionWormhole>()
83
+ const REGION_CACHE_MAX = 16384
84
+
85
+ function wormholeOfRegion(seed: Checksum256Type, R: Region): RegionWormhole {
86
+ const cacheKey = `${seed}:${R.rx}:${R.ry}`
87
+ const cached = regionCache.get(cacheKey)
88
+ if (cached !== undefined) return cached
89
+ const result = deriveWormholeOfRegion(seed, R)
90
+ if (regionCache.size >= REGION_CACHE_MAX) regionCache.clear()
91
+ regionCache.set(cacheKey, result)
92
+ return result
93
+ }
94
+
95
+ function deriveWormholeOfRegion(seed: Checksum256Type, R: Region): RegionWormhole {
83
96
  const P = partnerRegion(seed, R)
84
97
  if (P.rx === R.rx && P.ry === R.ry) return null
85
98
  const key = pairKey(R, P)
@@ -322,9 +322,15 @@ export {
322
322
  calcCounterpartDelivery,
323
323
  cargoKey,
324
324
  cargoInputKey,
325
+ hasSourceCoupling,
326
+ hasIncomingCoupling,
325
327
  } from './scheduling/availability'
326
328
  export type {CargoInput, IncomingSource} from './scheduling/availability'
327
329
 
330
+ export {clusterStockAvailable} from './scheduling/cluster-stock'
331
+
332
+ export {calcClusterIntake, calcClustercraftDuration, INTAKE_RATE} from './capabilities/crafting'
333
+
328
334
  export {maxCraftable} from './capabilities/craftable'
329
335
 
330
336
  export {energyAtTime} from './scheduling/energy'
@@ -463,6 +469,7 @@ export {
463
469
  GATHERER_DEPTH_TABLE,
464
470
  GATHERER_DEPTH_MAX_TIER,
465
471
  gathererDepthForTier,
472
+ computeGathererYield,
466
473
  } from './derivation/capabilities'
467
474
  export type {
468
475
  GathererDepthParams,
@@ -577,7 +584,6 @@ export {
577
584
  ENGINE_DRAIN_REF_THM,
578
585
  computeGeneratorCap,
579
586
  computeGeneratorRech,
580
- computeGathererYield,
581
587
  computeGathererDrain,
582
588
  computeGathererDepth,
583
589
  computeLoaderMass,
@@ -472,6 +472,29 @@ export class ActionsManager extends BaseManager {
472
472
  return this.server.action('craftjob', params)
473
473
  }
474
474
 
475
+ clustercraft(
476
+ entityId: UInt64Type,
477
+ recipeId: number,
478
+ quantity: number,
479
+ sources: ServerContract.ActionParams.Type.cluster_source[],
480
+ target?: UInt64Type,
481
+ slot?: UInt8Type
482
+ ): Action {
483
+ const params: ServerContract.ActionParams.clustercraft = {
484
+ id: UInt64.from(entityId),
485
+ recipe_id: UInt16.from(recipeId),
486
+ quantity: UInt32.from(quantity),
487
+ sources,
488
+ }
489
+ if (target !== undefined) {
490
+ params.target = UInt64.from(target)
491
+ }
492
+ if (slot !== undefined) {
493
+ params.slot = UInt8.from(slot)
494
+ }
495
+ return this.server.action('clustercraft', params)
496
+ }
497
+
475
498
  claimjob(jobId: UInt64Type, shipId: UInt64Type): Action {
476
499
  const params: ServerContract.ActionParams.claimjob = {
477
500
  job_id: UInt64.from(jobId),
@@ -14,6 +14,7 @@ import {
14
14
  MODULE_WARP,
15
15
  } from '../capabilities/modules'
16
16
  import {decodeStat, decodeCraftedItemStats} from '../derivation/crafting'
17
+ import {computeGathererYield} from '../derivation/capabilities'
17
18
  import {computeEffectiveModuleStat} from '../derivation/stat-scaling'
18
19
  import {getStatDefinitions} from '../derivation/stats'
19
20
  import type {ResourceCategory} from '../types'
@@ -28,7 +29,6 @@ import {
28
29
  computeEngineThrust,
29
30
  computeGathererDepth,
30
31
  computeGathererDrain,
31
- computeGathererYield,
32
32
  computeGeneratorCap,
33
33
  computeGeneratorRech,
34
34
  computeCargoBayCapacity,
@@ -194,7 +194,7 @@ export function buildModuleImmutable(
194
194
  base.push({first: 'thermal', second: ['uint16', thm]})
195
195
  base.push({
196
196
  first: 'thrust',
197
- second: ['uint32', computeEngineThrust(computeEffectiveModuleStat(vol))],
197
+ second: ['uint32', computeEngineThrust(computeEffectiveModuleStat(vol), item.tier)],
198
198
  })
199
199
  base.push({
200
200
  first: 'drain',
@@ -211,14 +211,14 @@ export function buildModuleImmutable(
211
211
  first: 'capacity',
212
212
  second: [
213
213
  'uint16',
214
- toWholeEnergy(computeGeneratorCap(computeEffectiveModuleStat(res))),
214
+ toWholeEnergy(computeGeneratorCap(computeEffectiveModuleStat(res), item.tier)),
215
215
  ],
216
216
  })
217
217
  base.push({
218
218
  first: 'recharge',
219
219
  second: [
220
220
  'uint16',
221
- toWholeEnergy(computeGeneratorRech(computeEffectiveModuleStat(ref))),
221
+ toWholeEnergy(computeGeneratorRech(computeEffectiveModuleStat(ref), item.tier)),
222
222
  ],
223
223
  })
224
224
  break
@@ -230,7 +230,10 @@ export function buildModuleImmutable(
230
230
  base.push({first: 'strength', second: ['uint16', str]})
231
231
  base.push({first: 'hardness', second: ['uint16', hrd]})
232
232
  base.push({first: 'saturation', second: ['uint16', sat]})
233
- base.push({first: 'yield', second: ['uint16', computeGathererYield(str)]})
233
+ base.push({
234
+ first: 'yield',
235
+ second: ['uint16', computeGathererYield(str, item.tier)],
236
+ })
234
237
  base.push({
235
238
  first: 'drain',
236
239
  second: ['uint16', toWholeEnergy(computeGathererDrain(sat))],
@@ -244,13 +247,13 @@ export function buildModuleImmutable(
244
247
  base.push({first: 'fineness', second: ['uint16', fin]})
245
248
  base.push({first: 'plasticity', second: ['uint16', pla]})
246
249
  base.push({first: 'mass', second: ['uint32', computeLoaderMass(fin)]})
247
- base.push({first: 'thrust', second: ['uint16', computeLoaderThrust(pla)]})
250
+ base.push({first: 'thrust', second: ['uint16', computeLoaderThrust(pla, item.tier)]})
248
251
  break
249
252
  }
250
253
  case MODULE_WARP: {
251
254
  const ref = decodeStat(stats, 0)
252
255
  base.push({first: 'reflectivity', second: ['uint16', ref]})
253
- base.push({first: 'range', second: ['uint32', computeWarpRange(ref)]})
256
+ base.push({first: 'range', second: ['uint32', computeWarpRange(ref, item.tier)]})
254
257
  break
255
258
  }
256
259
  case MODULE_CRAFTER: {
@@ -258,7 +261,7 @@ export function buildModuleImmutable(
258
261
  const con = decodeStat(stats, 1)
259
262
  base.push({first: 'fineness', second: ['uint16', fin]})
260
263
  base.push({first: 'conductivity', second: ['uint16', con]})
261
- base.push({first: 'speed', second: ['uint16', computeCrafterSpeed(fin)]})
264
+ base.push({first: 'speed', second: ['uint16', computeCrafterSpeed(fin, item.tier)]})
262
265
  base.push({first: 'drain', second: ['uint16', toWholeEnergy(computeCrafterDrain(con))]})
263
266
  break
264
267
  }
@@ -267,7 +270,7 @@ export function buildModuleImmutable(
267
270
  const fin = decodeStat(stats, 1)
268
271
  base.push({first: 'resonance', second: ['uint16', res]})
269
272
  base.push({first: 'fineness', second: ['uint16', fin]})
270
- base.push({first: 'speed', second: ['uint16', computeBuilderSpeed(res)]})
273
+ base.push({first: 'speed', second: ['uint16', computeBuilderSpeed(res, item.tier)]})
271
274
  base.push({first: 'drain', second: ['uint16', toWholeEnergy(computeBuilderDrain(fin))]})
272
275
  break
273
276
  }
@@ -16,18 +16,23 @@ import {
16
16
  ITEM_CONTAINER_T2_PACKED,
17
17
  ITEM_CONSTRUCTION_DOCK_T1_PACKED,
18
18
  ITEM_BUILDER_T1,
19
+ ITEM_BUILDER_T2,
19
20
  ITEM_CRAFTER_T1,
21
+ ITEM_CRAFTER_T2,
20
22
  ITEM_ENGINE_T1,
23
+ ITEM_ENGINE_T2,
21
24
  ITEM_EXTRACTOR_T1_PACKED,
22
25
  ITEM_FACTORY_T1_PACKED,
23
26
  ITEM_GATHERER_T1,
24
27
  ITEM_GATHERER_T2,
25
28
  ITEM_GENERATOR_T1,
29
+ ITEM_GENERATOR_T2,
26
30
  ITEM_HAULER_T1,
27
31
  ITEM_HAULER_T2,
28
32
  ITEM_BATTERY_T1,
29
33
  ITEM_LAUNCHER_T1,
30
34
  ITEM_LOADER_T1,
35
+ ITEM_LOADER_T2,
31
36
  ITEM_PROSPECTOR_T1A_PACKED,
32
37
  ITEM_PROSPECTOR_T2A_PACKED,
33
38
  ITEM_PROSPECTOR_T2B_PACKED,
@@ -42,9 +47,21 @@ import {
42
47
  ITEM_DREDGER_T2A_PACKED,
43
48
  ITEM_WAREHOUSE_T1_PACKED,
44
49
  ITEM_WARP_T1,
50
+ ITEM_WARP_T2,
45
51
  } from '../data/item-ids'
46
52
  import {decodeStat} from '../derivation/crafting'
47
- import {gathererDepthForTier} from '../derivation/capabilities'
53
+ import {
54
+ gathererDepthForTier,
55
+ computeGathererYield,
56
+ moduleTierPct,
57
+ ENGINE_THRUST_TIER_PCT,
58
+ GENERATOR_CAPACITY_TIER_PCT,
59
+ GENERATOR_RECHARGE_TIER_PCT,
60
+ CRAFTER_SPEED_TIER_PCT,
61
+ BUILDER_SPEED_TIER_PCT,
62
+ WARP_RANGE_TIER_PCT,
63
+ LOADER_THRUST_TIER_PCT,
64
+ } from '../derivation/capabilities'
48
65
  import {getItem} from '../data/catalog'
49
66
  import {ENTITY_SHIP, getPackedEntityType} from '../data/kind-registry'
50
67
  import {getBaseHullmassFor} from '../derivation/capabilities'
@@ -92,7 +109,8 @@ export function computeBaseCapacityWarehouse(stats: bigint): number {
92
109
  return Math.floor(100_000_000 * 6 ** (s / 1998))
93
110
  }
94
111
 
95
- export const computeEngineThrust = (vol: number): number => 400 + idiv(vol * 3, 4)
112
+ export const computeEngineThrust = (vol: number, tier: number): number =>
113
+ idiv((400 + idiv(vol * 3, 4)) * moduleTierPct(ENGINE_THRUST_TIER_PCT, tier), 100)
96
114
  export const computeEngineDrain = (thm: number): number => 2 * Math.max(30, 50 - idiv(thm, 70))
97
115
  export const ENGINE_DRAIN_BASE = 156
98
116
  export const ENGINE_DRAIN_REF_THRUST = 775
@@ -104,19 +122,23 @@ export const computeTravelDrain = (totalThrust: number, avgThm: number): number
104
122
  const thrustFactor = Math.sqrt(ENGINE_DRAIN_REF_THRUST / totalThrust)
105
123
  return Math.floor(ENGINE_DRAIN_BASE * 1000 * thermalFactor * thrustFactor)
106
124
  }
107
- export const computeGeneratorCap = (com: number): number => 1_300_000 + com * 500
108
- export const computeGeneratorRech = (fin: number): number => 2000 + fin * 6
109
- export const computeGathererYield = (str: number): number => 200 + str
125
+ export const computeGeneratorCap = (com: number, tier: number): number =>
126
+ idiv((1_300_000 + com * 500) * moduleTierPct(GENERATOR_CAPACITY_TIER_PCT, tier), 100)
127
+ export const computeGeneratorRech = (fin: number, tier: number): number =>
128
+ idiv((2000 + fin * 6) * moduleTierPct(GENERATOR_RECHARGE_TIER_PCT, tier), 100)
110
129
  export const computeGathererDrain = (con: number): number =>
111
130
  2 * Math.max(250_000, 1_250_000 - con * 1250)
112
131
  export const computeGathererDepth = (tol: number, tier: number): number =>
113
132
  gathererDepthForTier(tol, tier)
114
133
  export const computeLoaderMass = (ins: number): number => Math.max(200, 2000 - ins * 2)
115
- export const computeLoaderThrust = (pla: number): number => 1 + idiv(pla * pla, 10000)
116
- export const computeCrafterSpeed = (rea: number): number => 100 + idiv(rea * 4, 5)
134
+ export const computeLoaderThrust = (pla: number, tier: number): number =>
135
+ idiv((1 + idiv(pla * pla, 10000)) * moduleTierPct(LOADER_THRUST_TIER_PCT, tier), 100)
136
+ export const computeCrafterSpeed = (rea: number, tier: number): number =>
137
+ idiv((100 + idiv(rea * 4, 5)) * moduleTierPct(CRAFTER_SPEED_TIER_PCT, tier), 100)
117
138
  export const computeCrafterDrain = (fin: number): number =>
118
139
  Math.max(5000, 30000 - idiv(fin * 1000, 33))
119
- export const computeBuilderSpeed = (resonance: number): number => 100 + idiv(resonance * 4, 5)
140
+ export const computeBuilderSpeed = (resonance: number, tier: number): number =>
141
+ idiv((100 + idiv(resonance * 4, 5)) * moduleTierPct(BUILDER_SPEED_TIER_PCT, tier), 100)
120
142
  export const computeBuilderDrain = (fineness: number): number =>
121
143
  Math.max(5000, 30000 - idiv(fineness * 1000, 33))
122
144
  export const computeHaulerCapacity = (fin: number, tier: number): number =>
@@ -132,7 +154,8 @@ export const computeHaulerDrain = (conductivity: number, tier: number): number =
132
154
  idiv(computeT1LogisticsDrain(conductivity) * supportDrainTierPercent(tier), 100)
133
155
  export const computeCargoBayDrain = (cohesion: number, tier: number): number =>
134
156
  idiv(computeT1LogisticsDrain(cohesion) * 3 * supportDrainTierPercent(tier), 400)
135
- export const computeWarpRange = (stat: number): number => 100 + stat * 3
157
+ export const computeWarpRange = (stat: number, tier: number): number =>
158
+ idiv((100 + stat * 3) * moduleTierPct(WARP_RANGE_TIER_PCT, tier), 100)
136
159
  export const computeCargoBayCapacity = (
137
160
  strength: number,
138
161
  density: number,
@@ -189,17 +212,22 @@ export function entityDisplayName(itemId: number): string {
189
212
  export function moduleDisplayName(itemId: number): string {
190
213
  switch (itemId) {
191
214
  case ITEM_ENGINE_T1:
215
+ case ITEM_ENGINE_T2:
192
216
  return 'Engine'
193
217
  case ITEM_GENERATOR_T1:
218
+ case ITEM_GENERATOR_T2:
194
219
  return 'Power Core'
195
220
  case ITEM_GATHERER_T1:
196
221
  case ITEM_GATHERER_T2:
197
222
  return 'Limpet Bay'
198
223
  case ITEM_LOADER_T1:
224
+ case ITEM_LOADER_T2:
199
225
  return 'Shuttle Bay'
200
226
  case ITEM_CRAFTER_T1:
227
+ case ITEM_CRAFTER_T2:
201
228
  return 'Fabricator'
202
229
  case ITEM_BUILDER_T1:
230
+ case ITEM_BUILDER_T2:
203
231
  return 'Assembly Arm'
204
232
  case ITEM_STORAGE_T1:
205
233
  return 'Cargo Hold'
@@ -207,6 +235,7 @@ export function moduleDisplayName(itemId: number): string {
207
235
  case ITEM_HAULER_T2:
208
236
  return 'Tractor Beam'
209
237
  case ITEM_WARP_T1:
238
+ case ITEM_WARP_T2:
210
239
  return 'Warp Drive'
211
240
  case ITEM_BATTERY_T1:
212
241
  return 'Battery Bank'
@@ -231,14 +260,16 @@ export function formatModuleLine(slot: number, itemId: number, stats: bigint): s
231
260
  case MODULE_ENGINE: {
232
261
  const vol = computeEffectiveModuleStat(decodeStat(stats, 0))
233
262
  const thm = computeEffectiveModuleStat(decodeStat(stats, 1))
234
- out += ` Thrust ${computeEngineThrust(vol)} Drain ${computeEngineDrain(thm)}`
263
+ const tier = getItem(itemId).tier
264
+ out += ` Thrust ${computeEngineThrust(vol, tier)} Drain ${computeEngineDrain(thm)}`
235
265
  break
236
266
  }
237
267
  case MODULE_GENERATOR: {
238
268
  const res = computeEffectiveModuleStat(decodeStat(stats, 0))
239
269
  const ref = computeEffectiveModuleStat(decodeStat(stats, 1))
240
- out += ` Capacity ${toWholeEnergy(computeGeneratorCap(res))} Recharge ${toWholeEnergy(
241
- computeGeneratorRech(ref)
270
+ const tier = getItem(itemId).tier
271
+ out += ` Capacity ${toWholeEnergy(computeGeneratorCap(res, tier))} Recharge ${toWholeEnergy(
272
+ computeGeneratorRech(ref, tier)
242
273
  )}`
243
274
  break
244
275
  }
@@ -247,7 +278,7 @@ export function formatModuleLine(slot: number, itemId: number, stats: bigint): s
247
278
  const tol = decodeStat(stats, 1)
248
279
  const con = decodeStat(stats, 2)
249
280
  const tier = getItem(itemId).tier
250
- out += ` Yield ${computeGathererYield(str)} Depth ${computeGathererDepth(
281
+ out += ` Yield ${computeGathererYield(str, tier)} Depth ${computeGathererDepth(
251
282
  tol,
252
283
  tier
253
284
  )} Drain ${toWholeEnergy(computeGathererDrain(con))}`
@@ -256,19 +287,22 @@ export function formatModuleLine(slot: number, itemId: number, stats: bigint): s
256
287
  case MODULE_LOADER: {
257
288
  const fin = decodeStat(stats, 0)
258
289
  const pla = decodeStat(stats, 1)
259
- out += ` Mass ${computeLoaderMass(fin)} Thrust ${computeLoaderThrust(pla)}`
290
+ const tier = getItem(itemId).tier
291
+ out += ` Mass ${computeLoaderMass(fin)} Thrust ${computeLoaderThrust(pla, tier)}`
260
292
  break
261
293
  }
262
294
  case MODULE_CRAFTER: {
263
295
  const rea = decodeStat(stats, 0)
264
296
  const con = decodeStat(stats, 1)
265
- out += ` Speed ${computeCrafterSpeed(rea)} Drain ${toWholeEnergy(computeCrafterDrain(con))}`
297
+ const tier = getItem(itemId).tier
298
+ out += ` Speed ${computeCrafterSpeed(rea, tier)} Drain ${toWholeEnergy(computeCrafterDrain(con))}`
266
299
  break
267
300
  }
268
301
  case MODULE_BUILDER: {
269
302
  const res = decodeStat(stats, 0)
270
303
  const fin = decodeStat(stats, 1)
271
- out += ` Speed ${computeBuilderSpeed(res)} Drain ${toWholeEnergy(computeBuilderDrain(fin))}`
304
+ const tier = getItem(itemId).tier
305
+ out += ` Speed ${computeBuilderSpeed(res, tier)} Drain ${toWholeEnergy(computeBuilderDrain(fin))}`
272
306
  break
273
307
  }
274
308
  case MODULE_STORAGE: {
@@ -290,7 +324,8 @@ export function formatModuleLine(slot: number, itemId: number, stats: bigint): s
290
324
  }
291
325
  case MODULE_WARP: {
292
326
  const stat = decodeStat(stats, 0)
293
- out += ` Range ${computeWarpRange(stat)}`
327
+ const tier = getItem(itemId).tier
328
+ out += ` Range ${computeWarpRange(stat, tier)}`
294
329
  break
295
330
  }
296
331
  case MODULE_BATTERY: {
@@ -161,7 +161,7 @@ function computeCapabilityGroup(
161
161
  ): ResolvedAttributeGroup | undefined {
162
162
  switch (moduleType) {
163
163
  case MODULE_ENGINE: {
164
- const caps = computeEngineCapabilities(stats)
164
+ const caps = computeEngineCapabilities(stats, tier)
165
165
  return {
166
166
  capability: 'Engine',
167
167
  attributes: [
@@ -171,7 +171,7 @@ function computeCapabilityGroup(
171
171
  }
172
172
  }
173
173
  case MODULE_GENERATOR: {
174
- const caps = computeGeneratorCapabilities(stats)
174
+ const caps = computeGeneratorCapabilities(stats, tier)
175
175
  return {
176
176
  capability: 'Generator',
177
177
  attributes: [
@@ -192,7 +192,7 @@ function computeCapabilityGroup(
192
192
  }
193
193
  }
194
194
  case MODULE_LOADER: {
195
- const caps = computeLoaderCapabilities(stats)
195
+ const caps = computeLoaderCapabilities(stats, tier)
196
196
  return {
197
197
  capability: 'Loading',
198
198
  attributes: [
@@ -203,7 +203,7 @@ function computeCapabilityGroup(
203
203
  }
204
204
  }
205
205
  case MODULE_CRAFTER: {
206
- const caps = computeCrafterCapabilities(stats)
206
+ const caps = computeCrafterCapabilities(stats, tier)
207
207
  return {
208
208
  capability: 'Crafting',
209
209
  attributes: [
@@ -213,7 +213,7 @@ function computeCapabilityGroup(
213
213
  }
214
214
  }
215
215
  case MODULE_BUILDER: {
216
- const caps = computeBuilderCapabilities(stats)
216
+ const caps = computeBuilderCapabilities(stats, tier)
217
217
  return {
218
218
  capability: 'Build',
219
219
  attributes: [
@@ -4,8 +4,11 @@ import {
4
4
  calcCounterpartDelivery,
5
5
  cargoReadyAt,
6
6
  type CargoInput,
7
+ hasIncomingCoupling,
8
+ hasSourceCoupling,
7
9
  type IncomingSource,
8
10
  projectedCargoAvailableAt,
11
+ taskCargoEffect,
9
12
  } from './availability'
10
13
 
11
14
  const T0 = '2026-06-19T00:00:00'
@@ -33,6 +36,19 @@ function coupling(kind: number) {
33
36
  })
34
37
  }
35
38
 
39
+ function craftTask(
40
+ cargo: ReturnType<typeof cargoItem>[],
41
+ couplings: ReturnType<typeof coupling>[]
42
+ ) {
43
+ return ServerContract.Types.task.from({
44
+ type: TaskType.CRAFT,
45
+ duration: 60,
46
+ cancelable: 0,
47
+ cargo,
48
+ couplings,
49
+ })
50
+ }
51
+
36
52
  function entity(
37
53
  cargo: ReturnType<typeof cargoItem>[],
38
54
  tasks: ReturnType<typeof task>[] = [],
@@ -155,3 +171,34 @@ describe('projectedCargoAvailableAt — incoming', () => {
155
171
  expect(avail.get('7:0') ?? 0n).toBe(0n)
156
172
  })
157
173
  })
174
+
175
+ describe('clustercraft projection parity', () => {
176
+ const inputs = [cargoItem(1001, 0, 10), cargoItem(1002, 0, 5)]
177
+ const output = cargoItem(2001, 0, 1)
178
+
179
+ test('own-hold craft: inputs removed, output added', () => {
180
+ const eff = taskCargoEffect(craftTask([...inputs, output], []))
181
+ expect(eff.removed.length).toBe(2)
182
+ expect(eff.added).toEqual([output])
183
+ })
184
+
185
+ test('clustercraft to a target: no crafter debit, no crafter credit', () => {
186
+ const t = craftTask(
187
+ [...inputs, output],
188
+ [coupling(HoldKind.SOURCE), coupling(HoldKind.GATHER)]
189
+ )
190
+ expect(hasSourceCoupling(t)).toBe(true)
191
+ expect(hasIncomingCoupling(t)).toBe(true)
192
+ const eff = taskCargoEffect(t)
193
+ expect(eff.removed).toEqual([])
194
+ expect(eff.added).toEqual([])
195
+ })
196
+
197
+ test('clustercraft with target omitted: output falls back to crafter, still no input debit', () => {
198
+ const t = craftTask([...inputs, output], [coupling(HoldKind.SOURCE)])
199
+ expect(hasIncomingCoupling(t)).toBe(false)
200
+ const eff = taskCargoEffect(t)
201
+ expect(eff.removed).toEqual([])
202
+ expect(eff.added).toEqual([output])
203
+ })
204
+ })
@@ -36,6 +36,25 @@ export function isIncomingCouplingKind(kind: number): boolean {
36
36
  return INCOMING_COUPLING_KINDS.has(kind)
37
37
  }
38
38
 
39
+ // Mirrors has_source_hold: a HOLD_SOURCE coupling marks an escrowed clustercraft.
40
+ export function hasSourceCoupling(task: Task): boolean {
41
+ return task.couplings.some((c) => c.kind.toNumber() === HoldKind.SOURCE)
42
+ }
43
+
44
+ // Mirrors has_incoming_coupling: any PUSH/GATHER/FLIGHT coupling delivers output to a counterpart.
45
+ export function hasIncomingCoupling(task: Task): boolean {
46
+ return task.couplings.some((c) => isIncomingCouplingKind(c.kind.toNumber()))
47
+ }
48
+
49
+ // A CRAFT task's output ownership: clustered escrows inputs off other members; own-output lands on the crafter.
50
+ export function craftCargoOwnership(task: Task): {clustered: boolean; ownOutput: boolean} {
51
+ const clustered = hasSourceCoupling(task)
52
+ return {
53
+ clustered,
54
+ ownOutput: clustered ? !hasIncomingCoupling(task) : task.couplings.length === 0,
55
+ }
56
+ }
57
+
39
58
  // Mirrors calc_counterpart_delivery: incoming-kind couplings only; a coupled CRAFT yields its output slot.
40
59
  export function calcCounterpartDelivery(task: Task, coupling: Coupling): CargoItem[] {
41
60
  if (!isIncomingCouplingKind(coupling.kind.toNumber())) return []
@@ -58,12 +77,14 @@ export function taskCargoEffect(task: Task): CargoEffect {
58
77
  return task.couplings.length > 0
59
78
  ? {added: [], removed: []}
60
79
  : {added: task.cargo, removed: []}
61
- case TaskType.CRAFT:
80
+ case TaskType.CRAFT: {
62
81
  if (task.cargo.length === 0) return {added: [], removed: []}
82
+ const {clustered, ownOutput} = craftCargoOwnership(task)
63
83
  return {
64
- added: task.couplings.length === 0 ? [task.cargo[task.cargo.length - 1]] : [],
65
- removed: task.cargo.slice(0, -1),
84
+ added: ownOutput ? [task.cargo[task.cargo.length - 1]] : [],
85
+ removed: clustered ? [] : task.cargo.slice(0, -1),
66
86
  }
87
+ }
67
88
  default:
68
89
  return {added: [], removed: []}
69
90
  }
@@ -0,0 +1,31 @@
1
+ import {describe, expect, test} from 'bun:test'
2
+ import {ServerContract, TaskType} from '../index-module'
3
+ import {availableForItem} from './availability'
4
+ import {clusterStockAvailable} from './cluster-stock'
5
+
6
+ const AT = new Date('2026-06-19T00:00:00')
7
+
8
+ function cargoItem(itemId: number, stats: number, quantity: number) {
9
+ return ServerContract.Types.cargo_item.from({item_id: itemId, stats, modules: [], quantity})
10
+ }
11
+
12
+ function member(cargo: ReturnType<typeof cargoItem>[]) {
13
+ return {cargo, tasks: [], lanes: []} as unknown as Parameters<
14
+ typeof clusterStockAvailable
15
+ >[0][number]
16
+ }
17
+
18
+ describe('clusterStockAvailable', () => {
19
+ test('sums the same item split across two members', () => {
20
+ const stock = clusterStockAvailable(
21
+ [member([cargoItem(1001, 0, 30)]), member([cargoItem(1001, 0, 20)])],
22
+ AT
23
+ )
24
+ expect(availableForItem(stock, 1001)).toBe(50n)
25
+ })
26
+
27
+ test('an empty cluster yields nothing', () => {
28
+ const stock = clusterStockAvailable([], AT)
29
+ expect(availableForItem(stock, 1001)).toBe(0n)
30
+ })
31
+ })
@@ -0,0 +1,15 @@
1
+ import {type AvailabilityInput, projectedCargoAvailableAt} from './availability'
2
+
3
+ // Union of every cluster member's present available cargo (present-only; incoming/future not credited).
4
+ export function clusterStockAvailable(
5
+ members: readonly AvailabilityInput[],
6
+ at: Date
7
+ ): Map<string, bigint> {
8
+ const total = new Map<string, bigint>()
9
+ for (const member of members) {
10
+ for (const [key, qty] of projectedCargoAvailableAt(member, at)) {
11
+ total.set(key, (total.get(key) ?? 0n) + qty)
12
+ }
13
+ }
14
+ return total
15
+ }
@@ -1,7 +1,8 @@
1
1
  import {UInt8, UInt16, UInt64} from '@wharfkit/antelope'
2
2
  import {expect, test} from 'bun:test'
3
3
  import {encodeStats} from '../derivation/crafting'
4
- import {computeGathererYield, computeGathererDepth, computeGathererDrain} from '../nft/description'
4
+ import {computeGathererDepth, computeGathererDrain} from '../nft/description'
5
+ import {computeGathererYield} from '../derivation/capabilities'
5
6
  import {computeLoaderThrust, computeLoaderMass} from '../nft/description'
6
7
  import {computeCrafterSpeed, computeCrafterDrain} from '../nft/description'
7
8
  import {applySlotMultiplier} from '../entities/slot-multiplier'
@@ -77,8 +78,8 @@ test('resolveLaneGatherer reads the layout slot amp and applies it to yield (par
77
78
 
78
79
  // The resolver routes through the real layout's amp, not a hardcoded 100.
79
80
  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))
81
+ // Yield equals the contract formula clamp_to_uint16(compute_gatherer_yield(str, tier) * amp / 100).
82
+ expect(result.yield).toBe(applySlotMultiplier(computeGathererYield(GATH_STR, 1), ampFromLayout))
82
83
  expect(result.drain).toBe(computeGathererDrain(GATH_CON))
83
84
  expect(result.depth).toBe(computeGathererDepth(GATH_TOL, 1))
84
85
  expect(result.slotIndex).toBe(GATHERER_SLOT_IDX)
@@ -86,11 +87,11 @@ test('resolveLaneGatherer reads the layout slot amp and applies it to yield (par
86
87
 
87
88
  test('resolveLaneGatherer amp-scaling parity holds for a non-100 amp', () => {
88
89
  // 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)
90
+ expect(applySlotMultiplier(computeGathererYield(GATH_STR, 1), 80)).toBe(
91
+ Math.min(Math.floor((computeGathererYield(GATH_STR, 1) * 80) / 100), 65535)
91
92
  )
92
- expect(applySlotMultiplier(computeGathererYield(GATH_STR), 120)).toBe(
93
- Math.floor((computeGathererYield(GATH_STR) * 120) / 100)
93
+ expect(applySlotMultiplier(computeGathererYield(GATH_STR, 1), 120)).toBe(
94
+ Math.floor((computeGathererYield(GATH_STR, 1) * 120) / 100)
94
95
  )
95
96
  })
96
97
 
@@ -116,7 +117,7 @@ test('resolveLaneCrafter returns correct stats for slot 0 (laneKey=1)', () => {
116
117
  const layout = getEntityLayout(ITEM_CRAFTER_T1)?.slots ?? []
117
118
  const amp = getSlotAmp(layout, 0)
118
119
  expect(result.slotIndex).toBe(0)
119
- expect(result.speed).toBe(applySlotMultiplier(computeCrafterSpeed(CRAFTER_REA), amp))
120
+ expect(result.speed).toBe(applySlotMultiplier(computeCrafterSpeed(CRAFTER_REA, 1), amp))
120
121
  expect(result.drain).toBe(computeCrafterDrain(CRAFTER_FIN))
121
122
  expect(result.outputPct).toBe(amp)
122
123
  })
@@ -144,7 +145,7 @@ test('resolveLaneLoader returns correct stats for slot 0 (laneKey=1)', () => {
144
145
  const amp = getSlotAmp(layout, 0)
145
146
  expect(result.valid).toBe(true)
146
147
  expect(result.slotIndex).toBe(0)
147
- expect(result.thrust).toBe(applySlotMultiplier(computeLoaderThrust(LOADER_PLA), amp))
148
+ expect(result.thrust).toBe(applySlotMultiplier(computeLoaderThrust(LOADER_PLA, 1), amp))
148
149
  expect(result.mass).toBe(computeLoaderMass(LOADER_INS))
149
150
  expect(result.outputPct).toBe(amp)
150
151
  })