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

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.
@@ -187,7 +187,7 @@ function legacyEntityIdToItemId(id: string): number {
187
187
  case 'warehouse-t1':
188
188
  return 10202
189
189
  case 'container-t2':
190
- return 20200
190
+ return 11200
191
191
  default:
192
192
  return 0
193
193
  }
@@ -15,6 +15,7 @@ import {
15
15
  ITEM_CRAFTER_T1,
16
16
  ITEM_BUILDER_T1,
17
17
  ITEM_EXTRACTOR_T1_PACKED,
18
+ ITEM_WORKSHOP_T1_PACKED,
18
19
  ITEM_ROUSTABOUT_T1A_PACKED,
19
20
  ITEM_PROSPECTOR_T1A_PACKED,
20
21
  ITEM_TENDER_T1A_PACKED,
@@ -36,6 +37,7 @@ test('getRecipeConsumers lists every recipe that consumes Sensor', () => {
36
37
  ITEM_CRAFTER_T1,
37
38
  ITEM_BUILDER_T1,
38
39
  ITEM_EXTRACTOR_T1_PACKED,
40
+ ITEM_WORKSHOP_T1_PACKED,
39
41
  ITEM_ROUSTABOUT_T1A_PACKED,
40
42
  ].sort((a, b) => a - b)
41
43
  )
@@ -51,6 +51,7 @@ export {
51
51
  computeFreeCells,
52
52
  NftManager,
53
53
  ConstructionManager,
54
+ JobsManager,
54
55
  } from './managers'
55
56
  export type {GridCell, ClusterCell, Cluster} from './managers'
56
57
  export type {
@@ -288,8 +289,15 @@ export type {
288
289
  export {taskCargoChanges} from './scheduling/task-cargo'
289
290
  export type {TaskCargoChange, TaskCargoDirection} from './scheduling/task-cargo'
290
291
 
291
- export {jobsToLanes, pickFabricator, socketTail, JOB_QUEUE_CAP} from './scheduling/jobs'
292
- export type {JobWindow, JobLane, JobLaneEntry} from './scheduling/jobs'
292
+ export {
293
+ jobsToLanes,
294
+ pickFabricator,
295
+ socketTail,
296
+ JOB_QUEUE_CAP,
297
+ jobStatus,
298
+ splitJobCargo,
299
+ } from './scheduling/jobs'
300
+ export type {JobWindow, JobLane, JobLaneEntry, JobStatus, OwnedJob} from './scheduling/jobs'
293
301
 
294
302
  export {composeIdleResolve} from './scheduling/idle-resolve'
295
303
  export type {CounterpartLookup, IdleResolveTarget} from './scheduling/idle-resolve'
@@ -11,6 +11,7 @@ import {EpochsManager} from './epochs'
11
11
  import {ActionsManager} from './actions'
12
12
  import {ClusterManager} from './cluster'
13
13
  import {NftManager} from './nft'
14
+ import {JobsManager} from './jobs'
14
15
  import {SubscriptionsManager} from '../subscriptions/manager'
15
16
 
16
17
  export class GameContext {
@@ -22,6 +23,7 @@ export class GameContext {
22
23
  private _actions?: ActionsManager
23
24
  private _clusters?: ClusterManager
24
25
  private _nft?: NftManager
26
+ private _jobs?: JobsManager
25
27
  private _subscriptions?: SubscriptionsManager
26
28
  private _subscriptionsUrl?: string
27
29
 
@@ -91,6 +93,13 @@ export class GameContext {
91
93
  return this._nft
92
94
  }
93
95
 
96
+ get jobs(): JobsManager {
97
+ if (!this._jobs) {
98
+ this._jobs = new JobsManager(this)
99
+ }
100
+ return this._jobs
101
+ }
102
+
94
103
  setSubscriptionsUrl(url: string) {
95
104
  this._subscriptionsUrl = url
96
105
  }
@@ -12,6 +12,7 @@ export {ClusterManager, computeFreeCells} from './cluster'
12
12
  export type {GridCell, ClusterCell, Cluster} from './cluster'
13
13
  export {NftManager} from './nft'
14
14
  export type {NftConfigForItem} from './nft'
15
+ export {JobsManager} from './jobs'
15
16
  export {PlotManager} from './plot'
16
17
  export type {PlotProgress, PlotProgressInputRow} from './plot'
17
18
  export {ConstructionManager} from './construction'
@@ -0,0 +1,62 @@
1
+ import {describe, expect, it} from 'bun:test'
2
+ import {Name} from '@wharfkit/antelope'
3
+ import {JobsManager} from './jobs'
4
+
5
+ const OWNER = 'eggmaple.gm'
6
+ const row = (over: Record<string, unknown> = {}) => ({
7
+ id: {toNumber: () => 7},
8
+ workshop: {toNumber: () => 42},
9
+ socket: {toNumber: () => 0},
10
+ owner: Name.from(OWNER),
11
+ ship_id: {toNumber: () => 100},
12
+ coords: {x: {toNumber: () => 12}, y: {toNumber: () => 34}},
13
+ starts_at: {toDate: () => new Date('2026-07-26T10:00:00Z')},
14
+ completes_at: {toDate: () => new Date('2026-07-26T11:00:00Z')},
15
+ recipe_id: {toNumber: () => 10001},
16
+ quantity: {toNumber: () => 5},
17
+ energy_paid: {toNumber: () => 0},
18
+ cargo: [{item: 'in'}, {item: 'out'}],
19
+ ...over,
20
+ })
21
+
22
+ function managerWith(queryImpl: () => Promise<unknown[]>, allImpl: () => Promise<unknown[]>) {
23
+ const table = () => ({query: () => ({all: queryImpl}), all: allImpl})
24
+ const ctx = {server: {table}} as never
25
+ return new JobsManager(ctx)
26
+ }
27
+
28
+ describe('JobsManager.getOwnedJobs', () => {
29
+ it('parses owner rows: output = last cargo, inputs = rest, status derived', async () => {
30
+ const m = managerWith(
31
+ async () => [row()],
32
+ async () => []
33
+ )
34
+ const jobs = await m.getOwnedJobs(OWNER, {now: new Date('2026-07-26T11:30:00Z')})
35
+ expect(jobs).toHaveLength(1)
36
+ expect(jobs[0]).toMatchObject({id: 7, workshop: 42, quantity: 5, status: 'ready'})
37
+ expect(jobs[0].coords).toEqual({x: 12, y: 34})
38
+ expect(jobs[0].output).toEqual({item: 'out'} as never)
39
+ expect(jobs[0].inputs).toEqual([{item: 'in'}] as never)
40
+ })
41
+
42
+ it('drops rows whose owner does not match (positional-index safety re-filter)', async () => {
43
+ const other = row({owner: Name.from('someoneelse.gm')})
44
+ const m = managerWith(
45
+ async () => [row(), other],
46
+ async () => []
47
+ )
48
+ const jobs = await m.getOwnedJobs(OWNER)
49
+ expect(jobs).toHaveLength(1)
50
+ })
51
+
52
+ it('falls back to a full-scan .all() when the positional query throws', async () => {
53
+ const m = managerWith(
54
+ async () => {
55
+ throw new Error('bad index_position')
56
+ },
57
+ async () => [row()]
58
+ )
59
+ const jobs = await m.getOwnedJobs(OWNER)
60
+ expect(jobs).toHaveLength(1)
61
+ })
62
+ })
@@ -0,0 +1,52 @@
1
+ import {Name, type NameType} from '@wharfkit/antelope'
2
+ import {BaseManager} from './base'
3
+ import {jobStatus, splitJobCargo, type OwnedJob} from '../scheduling/jobs'
4
+ import type {ServerContract} from '../contracts'
5
+
6
+ type JobRow = ServerContract.Types.job_row
7
+
8
+ export class JobsManager extends BaseManager {
9
+ async getOwnedJobs(owner: NameType, opts?: {now?: Date}): Promise<OwnedJob[]> {
10
+ const ownerName = Name.from(owner)
11
+ const now = opts?.now ?? new Date()
12
+
13
+ let rows: JobRow[]
14
+ try {
15
+ // index_position 'tertiary' = nodeos slot 3 = owner's secondary index; no ABI metadata for it.
16
+ rows = (await this.server
17
+ .table('jobs')
18
+ .query({
19
+ index_position: 'tertiary',
20
+ key_type: 'i64',
21
+ from: ownerName.value,
22
+ to: ownerName.value,
23
+ })
24
+ .all()) as JobRow[]
25
+ } catch {
26
+ rows = (await this.server.table('jobs').all()) as JobRow[]
27
+ }
28
+
29
+ return rows.filter((r) => ownerName.equals(r.owner)).map((r) => this.parseOwnedJob(r, now))
30
+ }
31
+
32
+ private parseOwnedJob(r: JobRow, now: Date): OwnedJob {
33
+ const startsAt = r.starts_at.toDate()
34
+ const completesAt = r.completes_at.toDate()
35
+ const {output, inputs} = splitJobCargo(r.cargo)
36
+ return {
37
+ id: r.id.toNumber(),
38
+ workshop: r.workshop.toNumber(),
39
+ socket: r.socket.toNumber(),
40
+ shipId: r.ship_id.toNumber(),
41
+ coords: {x: r.coords.x.toNumber(), y: r.coords.y.toNumber()},
42
+ startsAt,
43
+ completesAt,
44
+ recipeId: r.recipe_id.toNumber(),
45
+ quantity: r.quantity.toNumber(),
46
+ status: jobStatus({startsAt, completesAt}, now),
47
+ output,
48
+ inputs,
49
+ outputStats: output?.stats === undefined ? undefined : BigInt(output.stats.toString()),
50
+ }
51
+ }
52
+ }
@@ -285,7 +285,7 @@ export function buildModuleImmutable(
285
285
  base.push({first: 'cohesion', second: ['uint16', com]})
286
286
  base.push({
287
287
  first: 'capacity',
288
- second: ['uint32', computeCargoBayCapacity(str, den, hrd)],
288
+ second: ['uint32', computeCargoBayCapacity(str, den, hrd, item.tier)],
289
289
  })
290
290
  base.push({
291
291
  first: 'drain',
@@ -304,7 +304,10 @@ export function buildModuleImmutable(
304
304
  base.push({first: 'insulation', second: ['uint16', ins]})
305
305
  base.push({
306
306
  first: 'capacity',
307
- second: ['uint32', toWholeEnergy(computeBatteryBankCapacity(vol, thm, pla, ins))],
307
+ second: [
308
+ 'uint32',
309
+ toWholeEnergy(computeBatteryBankCapacity(vol, thm, pla, ins, item.tier)),
310
+ ],
308
311
  })
309
312
  break
310
313
  }
@@ -11,44 +11,7 @@ import {
11
11
  MODULE_STORAGE,
12
12
  MODULE_WARP,
13
13
  } from '../capabilities/modules'
14
- import {
15
- ITEM_CONTAINER_T1_PACKED,
16
- ITEM_CONTAINER_T2_PACKED,
17
- ITEM_CONSTRUCTION_DOCK_T1_PACKED,
18
- ITEM_BUILDER_T1,
19
- ITEM_BUILDER_T2,
20
- ITEM_CRAFTER_T1,
21
- ITEM_CRAFTER_T2,
22
- ITEM_ENGINE_T1,
23
- ITEM_ENGINE_T2,
24
- ITEM_EXTRACTOR_T1_PACKED,
25
- ITEM_FACTORY_T1_PACKED,
26
- ITEM_GATHERER_T1,
27
- ITEM_GATHERER_T2,
28
- ITEM_GENERATOR_T1,
29
- ITEM_GENERATOR_T2,
30
- ITEM_HAULER_T1,
31
- ITEM_HAULER_T2,
32
- ITEM_BATTERY_T1,
33
- ITEM_LAUNCHER_T1,
34
- ITEM_LOADER_T1,
35
- ITEM_LOADER_T2,
36
- ITEM_PROSPECTOR_T1A_PACKED,
37
- ITEM_PROSPECTOR_T2A_PACKED,
38
- ITEM_PROSPECTOR_T2B_PACKED,
39
- ITEM_ROUSTABOUT_T1A_PACKED,
40
- ITEM_SHIP_T1_PACKED,
41
- ITEM_SMITH_T1A_PACKED,
42
- ITEM_STORAGE_T1,
43
- ITEM_TENDER_T1A_PACKED,
44
- ITEM_TUG_T1A_PACKED,
45
- ITEM_PORTER_T1A_PACKED,
46
- ITEM_WRIGHT_T1A_PACKED,
47
- ITEM_DREDGER_T2A_PACKED,
48
- ITEM_WAREHOUSE_T1_PACKED,
49
- ITEM_WARP_T1,
50
- ITEM_WARP_T2,
51
- } from '../data/item-ids'
14
+ import {getKindMeta, getTemplateMeta} from '../data/kind-registry'
52
15
  import {decodeStat} from '../derivation/crafting'
53
16
  import {
54
17
  gathererDepthForTier,
@@ -61,8 +24,11 @@ import {
61
24
  BUILDER_SPEED_TIER_PCT,
62
25
  WARP_RANGE_TIER_PCT,
63
26
  LOADER_THRUST_TIER_PCT,
27
+ CARGO_BAY_CAPACITY_TIER_PCT,
28
+ BATTERY_CAPACITY_TIER_PCT,
64
29
  } from '../derivation/capabilities'
65
- import {getItem} from '../data/catalog'
30
+ import {getItem, tryGetItem} from '../data/catalog'
31
+ import type {ModuleType} from '../types'
66
32
  import {ENTITY_SHIP, getPackedEntityType} from '../data/kind-registry'
67
33
  import {getBaseHullmassFor} from '../derivation/capabilities'
68
34
  import {computeEffectiveModuleStat} from '../derivation/stat-scaling'
@@ -75,6 +41,11 @@ export function toWholeEnergy(milli: number): number {
75
41
  return idiv(milli + 500, 1000)
76
42
  }
77
43
 
44
+ export function formatMassTonnes(kg: number): string {
45
+ const tenths = idiv(kg + 50, 100)
46
+ return `${idiv(tenths, 10)}.${tenths % 10} t`
47
+ }
48
+
78
49
  function isShipHull(itemId: number): boolean {
79
50
  return getPackedEntityType(itemId)?.equals(ENTITY_SHIP) ?? false
80
51
  }
@@ -109,6 +80,29 @@ export function computeBaseCapacityWarehouse(stats: bigint): number {
109
80
  return Math.floor(100_000_000 * 6 ** (s / 1998))
110
81
  }
111
82
 
83
+ export function computeBaseCapacityWorkshop(stats: bigint): number {
84
+ const s = decodeStat(stats, 0) + decodeStat(stats, 2)
85
+ return Math.floor(5_000_000 * 6 ** (s / 1998))
86
+ }
87
+
88
+ const CAPACITY_FN_BY_KIND: Record<string, (stats: bigint) => number> = {
89
+ ship: computeBaseCapacityShip,
90
+ warehouse: computeBaseCapacityWarehouse,
91
+ workshop: computeBaseCapacityWorkshop,
92
+ extractor: computeBaseCapacityContainer,
93
+ factory: computeBaseCapacityContainer,
94
+ builddock: computeBaseCapacityContainer,
95
+ mdriver: computeBaseCapacityContainer,
96
+ mcatcher: computeBaseCapacityContainer,
97
+ container: computeBaseCapacityContainer,
98
+ }
99
+
100
+ export function computeBaseCapacityForEntity(itemId: number, stats: bigint): number {
101
+ const kind = getTemplateMeta(itemId)?.kind
102
+ if (!kind) return 0
103
+ return CAPACITY_FN_BY_KIND[kind.toString()]?.(stats) ?? 0
104
+ }
105
+
112
106
  export const computeEngineThrust = (vol: number, tier: number): number =>
113
107
  idiv((400 + idiv(vol * 3, 4)) * moduleTierPct(ENGINE_THRUST_TIER_PCT, tier), 100)
114
108
  export const computeEngineDrain = (thm: number): number => 2 * Math.max(30, 50 - idiv(thm, 70))
@@ -159,91 +153,52 @@ export const computeWarpRange = (stat: number, tier: number): number =>
159
153
  export const computeCargoBayCapacity = (
160
154
  strength: number,
161
155
  density: number,
162
- hardness: number
163
- ): number => 10_000_000 + idiv((strength + density + hardness) * 50_000_000, 2997)
156
+ hardness: number,
157
+ tier: number
158
+ ): number =>
159
+ idiv(
160
+ (10_000_000 + idiv((strength + density + hardness) * 50_000_000, 2997)) *
161
+ moduleTierPct(CARGO_BAY_CAPACITY_TIER_PCT, tier),
162
+ 100
163
+ )
164
164
  export const computeBatteryBankCapacity = (
165
165
  volatility: number,
166
166
  thermal: number,
167
167
  plasticity: number,
168
- insulation: number
169
- ): number => 2_500_000 + idiv((volatility + thermal + plasticity + insulation) * 7_500_000, 3996)
168
+ insulation: number,
169
+ tier: number
170
+ ): number =>
171
+ idiv(
172
+ (2_500_000 + idiv((volatility + thermal + plasticity + insulation) * 7_500_000, 3996)) *
173
+ moduleTierPct(BATTERY_CAPACITY_TIER_PCT, tier),
174
+ 100
175
+ )
170
176
 
171
177
  export function entityDisplayName(itemId: number): string {
172
- switch (itemId) {
173
- case ITEM_SHIP_T1_PACKED:
174
- return 'Ship'
175
- case ITEM_ROUSTABOUT_T1A_PACKED:
176
- return 'Roustabout'
177
- case ITEM_PROSPECTOR_T1A_PACKED:
178
- return 'Prospector'
179
- case ITEM_TENDER_T1A_PACKED:
180
- return 'Tender'
181
- case ITEM_TUG_T1A_PACKED:
182
- return 'Tug'
183
- case ITEM_PORTER_T1A_PACKED:
184
- return 'Porter'
185
- case ITEM_WRIGHT_T1A_PACKED:
186
- return 'Wright'
187
- case ITEM_SMITH_T1A_PACKED:
188
- return 'Smith'
189
- case ITEM_WAREHOUSE_T1_PACKED:
190
- return 'Warehouse'
191
- case ITEM_EXTRACTOR_T1_PACKED:
192
- return 'Mining Rig'
193
- case ITEM_FACTORY_T1_PACKED:
194
- return 'Factory'
195
- case ITEM_CONSTRUCTION_DOCK_T1_PACKED:
196
- return 'Construction Dock'
197
- case ITEM_CONTAINER_T1_PACKED:
198
- return 'Container'
199
- case ITEM_CONTAINER_T2_PACKED:
200
- return 'Container'
201
- case ITEM_PROSPECTOR_T2A_PACKED:
202
- return 'Prospector'
203
- case ITEM_PROSPECTOR_T2B_PACKED:
204
- return 'Prospector'
205
- case ITEM_DREDGER_T2A_PACKED:
206
- return 'Dredger'
207
- default:
208
- return 'Entity'
209
- }
178
+ const template = getTemplateMeta(itemId)
179
+ if (!template) return 'Entity'
180
+ if (template.displayLabel) return template.displayLabel
181
+ return getKindMeta(template.kind)?.defaultLabel || 'Entity'
182
+ }
183
+
184
+ const MODULE_DISPLAY_NAME_BY_TYPE: Partial<Record<ModuleType, string>> = {
185
+ engine: 'Engine',
186
+ generator: 'Power Core',
187
+ gatherer: 'Limpet Bay',
188
+ loader: 'Shuttle Bay',
189
+ crafter: 'Fabricator',
190
+ storage: 'Cargo Hold',
191
+ hauler: 'Tractor Beam',
192
+ warp: 'Warp Drive',
193
+ battery: 'Battery Bank',
194
+ launcher: 'Drive Coil',
195
+ builder: 'Assembly Arm',
210
196
  }
211
197
 
212
198
  export function moduleDisplayName(itemId: number): string {
213
- switch (itemId) {
214
- case ITEM_ENGINE_T1:
215
- case ITEM_ENGINE_T2:
216
- return 'Engine'
217
- case ITEM_GENERATOR_T1:
218
- case ITEM_GENERATOR_T2:
219
- return 'Power Core'
220
- case ITEM_GATHERER_T1:
221
- case ITEM_GATHERER_T2:
222
- return 'Limpet Bay'
223
- case ITEM_LOADER_T1:
224
- case ITEM_LOADER_T2:
225
- return 'Shuttle Bay'
226
- case ITEM_CRAFTER_T1:
227
- case ITEM_CRAFTER_T2:
228
- return 'Fabricator'
229
- case ITEM_BUILDER_T1:
230
- case ITEM_BUILDER_T2:
231
- return 'Assembly Arm'
232
- case ITEM_STORAGE_T1:
233
- return 'Cargo Hold'
234
- case ITEM_HAULER_T1:
235
- case ITEM_HAULER_T2:
236
- return 'Tractor Beam'
237
- case ITEM_WARP_T1:
238
- case ITEM_WARP_T2:
239
- return 'Warp Drive'
240
- case ITEM_BATTERY_T1:
241
- return 'Battery Bank'
242
- case ITEM_LAUNCHER_T1:
243
- return 'Drive Coil'
244
- default:
245
- return 'Module'
246
- }
199
+ const item = tryGetItem(itemId)
200
+ if (item?.type !== 'module' || !item.moduleType) return 'Module'
201
+ return MODULE_DISPLAY_NAME_BY_TYPE[item.moduleType] ?? 'Module'
247
202
  }
248
203
 
249
204
  export function formatModuleLine(slot: number, itemId: number, stats: bigint): string {
@@ -288,7 +243,7 @@ export function formatModuleLine(slot: number, itemId: number, stats: bigint): s
288
243
  const fin = decodeStat(stats, 0)
289
244
  const pla = decodeStat(stats, 1)
290
245
  const tier = getItem(itemId).tier
291
- out += ` Mass ${computeLoaderMass(fin)} Thrust ${computeLoaderThrust(pla, tier)}`
246
+ out += ` Mass ${formatMassTonnes(computeLoaderMass(fin))} Thrust ${computeLoaderThrust(pla, tier)}`
292
247
  break
293
248
  }
294
249
  case MODULE_CRAFTER: {
@@ -311,7 +266,7 @@ export function formatModuleLine(slot: number, itemId: number, stats: bigint): s
311
266
  const hrd = decodeStat(stats, 2)
312
267
  const coh = decodeStat(stats, 3)
313
268
  const tier = getItem(itemId).tier
314
- out += ` Cargo Capacity ${computeCargoBayCapacity(str, den, hrd)} Drain ${toWholeEnergy(computeCargoBayDrain(coh, tier))}`
269
+ out += ` Cargo Capacity ${formatMassTonnes(computeCargoBayCapacity(str, den, hrd, tier))} Drain ${toWholeEnergy(computeCargoBayDrain(coh, tier))}`
315
270
  break
316
271
  }
317
272
  case MODULE_HAULER: {
@@ -333,7 +288,8 @@ export function formatModuleLine(slot: number, itemId: number, stats: bigint): s
333
288
  const thm = decodeStat(stats, 1)
334
289
  const pla = decodeStat(stats, 2)
335
290
  const ins = decodeStat(stats, 3)
336
- out += ` Energy Capacity ${toWholeEnergy(computeBatteryBankCapacity(vol, thm, pla, ins))}`
291
+ const tier = getItem(itemId).tier
292
+ out += ` Energy Capacity ${toWholeEnergy(computeBatteryBankCapacity(vol, thm, pla, ins, tier))}`
337
293
  break
338
294
  }
339
295
  }
@@ -347,25 +303,12 @@ export function buildEntityDescription(
347
303
  moduleStats: bigint[]
348
304
  ): string {
349
305
  const hullMass = computeBaseHullmass(itemId, hullStats)
350
- let baseCapacity = 0
351
- if (isShipHull(itemId)) {
352
- baseCapacity = computeBaseCapacityShip(hullStats)
353
- } else if (itemId === ITEM_WAREHOUSE_T1_PACKED) {
354
- baseCapacity = computeBaseCapacityWarehouse(hullStats)
355
- } else if (
356
- itemId === ITEM_EXTRACTOR_T1_PACKED ||
357
- itemId === ITEM_FACTORY_T1_PACKED ||
358
- itemId === ITEM_CONSTRUCTION_DOCK_T1_PACKED ||
359
- itemId === ITEM_CONTAINER_T1_PACKED ||
360
- itemId === ITEM_CONTAINER_T2_PACKED
361
- ) {
362
- baseCapacity = computeBaseCapacityContainer(hullStats)
363
- }
306
+ const baseCapacity = computeBaseCapacityForEntity(itemId, hullStats)
364
307
 
365
308
  let out = entityDisplayName(itemId)
366
- out += ` - Hull ${hullMass} mass`
309
+ out += ` - Hull ${formatMassTonnes(hullMass)}`
367
310
  if (baseCapacity > 0) {
368
- out += ` * ${baseCapacity} capacity`
311
+ out += ` * ${formatMassTonnes(baseCapacity)} capacity`
369
312
  }
370
313
  out += '\n\n'
371
314
 
@@ -247,7 +247,7 @@ function computeCapabilityGroup(
247
247
  }
248
248
  }
249
249
  case MODULE_BATTERY: {
250
- const caps = computeBatteryCapabilities(stats)
250
+ const caps = computeBatteryCapabilities(stats, tier)
251
251
  return {
252
252
  capability: 'Energy',
253
253
  attributes: [
@@ -0,0 +1,32 @@
1
+ import {describe, expect, it} from 'bun:test'
2
+ import {jobStatus, splitJobCargo} from './jobs'
3
+
4
+ const at = (s: string) => new Date(s)
5
+
6
+ describe('jobStatus', () => {
7
+ const job = {startsAt: at('2026-07-26T10:00:00Z'), completesAt: at('2026-07-26T11:00:00Z')}
8
+ it('is waiting before startsAt', () => {
9
+ expect(jobStatus(job, at('2026-07-26T09:59:59Z'))).toBe('waiting')
10
+ })
11
+ it('is crafting between startsAt and completesAt', () => {
12
+ expect(jobStatus(job, at('2026-07-26T10:30:00Z'))).toBe('crafting')
13
+ })
14
+ it('is ready at or after completesAt', () => {
15
+ expect(jobStatus(job, at('2026-07-26T11:00:00Z'))).toBe('ready')
16
+ expect(jobStatus(job, at('2026-07-26T12:00:00Z'))).toBe('ready')
17
+ })
18
+ })
19
+
20
+ describe('splitJobCargo', () => {
21
+ it('takes the last element as output, the rest as inputs', () => {
22
+ const cargo = [{n: 'a'}, {n: 'b'}, {n: 'out'}] as unknown as Parameters<
23
+ typeof splitJobCargo
24
+ >[0]
25
+ const {output, inputs} = splitJobCargo(cargo)
26
+ expect(output).toEqual({n: 'out'} as never)
27
+ expect(inputs).toEqual([{n: 'a'}, {n: 'b'}] as never)
28
+ })
29
+ it('returns null output for empty cargo', () => {
30
+ expect(splitJobCargo([])).toEqual({output: null, inputs: []})
31
+ })
32
+ })
@@ -1,3 +1,7 @@
1
+ import type {ServerContract} from '../contracts'
2
+
3
+ type CargoItem = ServerContract.Types.cargo_item
4
+
1
5
  export interface JobWindow {
2
6
  id: number
3
7
  socket: number
@@ -6,6 +10,8 @@ export interface JobWindow {
6
10
  completesAt: Date
7
11
  recipeId: number
8
12
  quantity: number
13
+ /** Packed stat roll of the job's output, when the source carried the job's cargo. */
14
+ outputStats?: bigint
9
15
  }
10
16
 
11
17
  export interface JobLaneEntry {
@@ -69,3 +75,34 @@ export function pickFabricator(
69
75
  }
70
76
  return best
71
77
  }
78
+
79
+ export type JobStatus = 'waiting' | 'crafting' | 'ready'
80
+
81
+ export function jobStatus(job: {startsAt: Date; completesAt: Date}, now: Date): JobStatus {
82
+ if (now < job.startsAt) return 'waiting'
83
+ if (now < job.completesAt) return 'crafting'
84
+ return 'ready'
85
+ }
86
+
87
+ // Generic in the element so raw chain JSON (readonly actions) splits by the same rule as decoded rows.
88
+ export function splitJobCargo<T>(cargo: readonly T[]): {output: T | null; inputs: T[]} {
89
+ if (cargo.length === 0) return {output: null, inputs: []}
90
+ return {output: cargo[cargo.length - 1], inputs: cargo.slice(0, -1)}
91
+ }
92
+
93
+ export interface OwnedJob {
94
+ id: number
95
+ workshop: number
96
+ socket: number
97
+ shipId: number
98
+ coords: {x: number; y: number}
99
+ startsAt: Date
100
+ completesAt: Date
101
+ recipeId: number
102
+ quantity: number
103
+ status: JobStatus
104
+ output: CargoItem | null
105
+ inputs: CargoItem[]
106
+ /** Packed stat roll of `output`, so every job shape answers this the same way. */
107
+ outputStats?: bigint
108
+ }
package/src/shipload.ts CHANGED
@@ -12,6 +12,7 @@ import type {EpochsManager} from './managers/epochs'
12
12
  import type {ActionsManager} from './managers/actions'
13
13
  import type {ClusterManager} from './managers/cluster'
14
14
  import type {NftManager} from './managers/nft'
15
+ import type {JobsManager} from './managers/jobs'
15
16
  import type {SubscriptionsManager} from './subscriptions/manager'
16
17
  import type {GameState} from './entities/gamestate'
17
18
 
@@ -132,6 +133,10 @@ export class Shipload {
132
133
  return this._context.nft
133
134
  }
134
135
 
136
+ get jobs(): JobsManager {
137
+ return this._context.jobs
138
+ }
139
+
135
140
  get subscriptions(): SubscriptionsManager {
136
141
  return this._context.subscriptions
137
142
  }