@shipload/sdk 1.0.0-next.73 → 1.0.0-next.74

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 (38) hide show
  1. package/lib/shipload.d.ts +502 -127
  2. package/lib/shipload.js +5871 -4906
  3. package/lib/shipload.js.map +1 -1
  4. package/lib/shipload.m.js +5858 -4900
  5. package/lib/shipload.m.js.map +1 -1
  6. package/lib/testing.d.ts +174 -47
  7. package/lib/testing.js +524 -142
  8. package/lib/testing.js.map +1 -1
  9. package/lib/testing.m.js +524 -142
  10. package/lib/testing.m.js.map +1 -1
  11. package/package.json +1 -1
  12. package/src/contracts/server.ts +457 -134
  13. package/src/data/entities.json +2 -2
  14. package/src/index-module.ts +22 -1
  15. package/src/influence/civic-shuttle.test.ts +58 -0
  16. package/src/influence/civic-shuttle.ts +52 -6
  17. package/src/influence/duration.ts +0 -65
  18. package/src/influence/index.ts +4 -5
  19. package/src/influence/influence.test.ts +51 -136
  20. package/src/influence/valuation.ts +75 -0
  21. package/src/managers/actions.ts +37 -10
  22. package/src/managers/balances.test.ts +33 -0
  23. package/src/managers/balances.ts +15 -0
  24. package/src/managers/context.ts +50 -11
  25. package/src/managers/index.ts +14 -0
  26. package/src/managers/jobs.test.ts +82 -1
  27. package/src/managers/jobs.ts +41 -2
  28. package/src/managers/nft.test.ts +173 -1
  29. package/src/managers/nft.ts +35 -2
  30. package/src/managers/shuttle.test.ts +562 -0
  31. package/src/managers/shuttle.ts +397 -0
  32. package/src/scheduling/availability.ts +10 -0
  33. package/src/scheduling/energy.ts +55 -1
  34. package/src/scheduling/jobs.test.ts +171 -1
  35. package/src/scheduling/jobs.ts +110 -13
  36. package/src/scheduling/projection.ts +2 -1
  37. package/src/scheduling/task-effects.ts +38 -0
  38. package/src/shipload.ts +23 -4
@@ -108,12 +108,12 @@
108
108
  "slots": [
109
109
  {
110
110
  "type": "builder",
111
- "outputPct": 200,
111
+ "outputPct": 75,
112
112
  "maxTier": 1
113
113
  },
114
114
  {
115
115
  "type": "builder",
116
- "outputPct": 200,
116
+ "outputPct": 75,
117
117
  "maxTier": 1
118
118
  }
119
119
  ],
@@ -62,6 +62,22 @@ export {
62
62
  JobsManager,
63
63
  InfluenceManager,
64
64
  } from './managers'
65
+ export {
66
+ ShuttleManager,
67
+ rankShuttleOptions,
68
+ shuttleReasonCode,
69
+ shuttleCandidates,
70
+ BOOKING_LEVEL_CODES,
71
+ OMITTED_REASONS,
72
+ } from './managers/shuttle'
73
+ export type {
74
+ ShuttleMode,
75
+ ShuttleOption,
76
+ ShuttleOptions,
77
+ ShuttleReason,
78
+ ShuttleReasonCode,
79
+ CandidateEntity,
80
+ } from './managers/shuttle'
65
81
  export type {GridCell, ClusterCell, Cluster} from './managers'
66
82
  export type {
67
83
  CharterProgress,
@@ -341,6 +357,7 @@ export {
341
357
  jobDropoffTask,
342
358
  jobCancelRoute,
343
359
  buildJobCancelRoute,
360
+ hostedDropoff,
344
361
  jobCancellable,
345
362
  jobCancellationBlockReason,
346
363
  jobDeposited,
@@ -356,7 +373,10 @@ export type {
356
373
  JobStatusInput,
357
374
  JobCancelRoute,
358
375
  JobCancellationBlockReason,
376
+ JobRouteOptions,
377
+ HostedLeg,
359
378
  OwnedJob,
379
+ BuildJob,
360
380
  PickupInFlight,
361
381
  } from './scheduling/jobs'
362
382
 
@@ -400,6 +420,7 @@ export {
400
420
  cargoInputKey,
401
421
  hasSourceCoupling,
402
422
  hasIncomingCoupling,
423
+ isHostedCivicLeg,
403
424
  } from './scheduling/availability'
404
425
  export type {CargoInput, IncomingHoldEntity, IncomingSource} from './scheduling/availability'
405
426
 
@@ -409,7 +430,7 @@ export {calcClusterIntake, calcClustercraftDuration, INTAKE_RATE} from './capabi
409
430
 
410
431
  export {maxCraftable} from './capabilities/craftable'
411
432
 
412
- export {energyAtTime} from './scheduling/energy'
433
+ export {energyAtTime, energyDrawsFunded} from './scheduling/energy'
413
434
 
414
435
  export * from './types/capabilities'
415
436
  export * from './types/entity'
@@ -12,6 +12,7 @@ import {
12
12
  pendingCivicTransfers,
13
13
  selectCivicShuttleBay,
14
14
  CIVIC_TRANSFER_PER_PLAYER_CAP,
15
+ type CivicShuttleBay,
15
16
  } from './civic-shuttle'
16
17
 
17
18
  type Lane = ServerContract.Types.lane
@@ -63,6 +64,32 @@ function shuttleTask(senderId: number, receiverId: number, group?: number): Task
63
64
  } as unknown as Task
64
65
  }
65
66
 
67
+ function civicTask(type: number, couplings: unknown[]): Task {
68
+ return {
69
+ type: UInt8.from(type),
70
+ duration: UInt32.from(60),
71
+ cancelable: UInt8.from(2),
72
+ cargo: [],
73
+ couplings,
74
+ } as unknown as Task
75
+ }
76
+
77
+ function pullCoupling(id: number) {
78
+ return {
79
+ counterpart: {entity_id: UInt64.from(id), entity_type: UInt8.from(1)},
80
+ hold: UInt64.from(1),
81
+ kind: UInt8.from(HoldKind.PULL),
82
+ }
83
+ }
84
+
85
+ function pushCoupling(id: number) {
86
+ return {
87
+ counterpart: {entity_id: UInt64.from(id), entity_type: UInt8.from(1)},
88
+ hold: UInt64.from(1),
89
+ kind: UInt8.from(HoldKind.PUSH),
90
+ }
91
+ }
92
+
66
93
  function makeLane(laneKey: number, tasks: Task[]): Lane {
67
94
  return {
68
95
  lane_key: UInt8.from(laneKey),
@@ -142,6 +169,13 @@ test('selectCivicShuttleBay takes the first free bay, then the lowest occupied o
142
169
  expect(selectCivicShuttleBay(modules, ITEM_DEPOT_T1_PACKED, bothBusy, T0)?.laneKey).toBe(1)
143
170
  })
144
171
 
172
+ test('selectCivicShuttleBay picks the earliest finish, not the first free bay', () => {
173
+ const modules = depotModules()
174
+ const lanes = [makeLane(1, [shuttleTask(20, 21)])]
175
+ const fast = (bay: CivicShuttleBay) => (bay.slotIndex === 0 ? 10 : 500)
176
+ expect(selectCivicShuttleBay(modules, ITEM_DEPOT_T1_PACKED, lanes, T0, fast)?.slotIndex).toBe(0)
177
+ })
178
+
145
179
  test('selectCivicShuttleBay returns nothing when the building has no loaders', () => {
146
180
  const modules = [makeModuleEntry(ITEM_STORAGE_T1, storageStats)]
147
181
  expect(selectCivicShuttleBay(modules, ITEM_DEPOT_T1_PACKED, [], T0)).toBeUndefined()
@@ -190,6 +224,30 @@ test('civicTransfersFrom counts only the ships a player sends from', () => {
190
224
  expect(civicTransfersFrom(entity, [99], T0).length).toBe(0)
191
225
  })
192
226
 
227
+ test('civic legs on bays count toward the player cap', () => {
228
+ const depot = {
229
+ lanes: [makeLane(1, [civicTask(TaskType.CIVIC_WITHDRAW, [pushCoupling(7)])])],
230
+ } as never
231
+ expect(civicTransfersFrom(depot, [7]).length).toBe(1)
232
+ })
233
+
234
+ test('a civic deposit leg reports the same player id as sender and receiver', () => {
235
+ const depot = {
236
+ lanes: [makeLane(1, [civicTask(TaskType.CIVIC_DEPOSIT, [pullCoupling(7)])])],
237
+ } as never
238
+ const [transfer] = pendingCivicTransfers(depot, T0)
239
+ expect(String(transfer.senderId)).toBe('7')
240
+ expect(String(transfer.receiverId)).toBe('7')
241
+ })
242
+
243
+ test('a civic pickup leg is not callable while CANCEL_NEVER', () => {
244
+ const task = civicTask(TaskType.CIVIC_WITHDRAW, [pushCoupling(7)])
245
+ ;(task as unknown as {cancelable: unknown}).cancelable = UInt8.from(0)
246
+ const depot = {lanes: [makeLane(1, [task])]} as never
247
+ const [transfer] = pendingCivicTransfers(depot, T0)
248
+ expect(transfer.callable).toBe(false)
249
+ })
250
+
193
251
  test('civicTransfersAtCap trips at the per-player cap', () => {
194
252
  const mine = Array.from({length: CIVIC_TRANSFER_PER_PLAYER_CAP}, (_, i) =>
195
253
  shuttleTask(20 + i, 50)
@@ -4,7 +4,7 @@ import {getItem} from '../data/catalog'
4
4
  import {JOB_QUEUE_CAP} from '../scheduling/jobs'
5
5
  import {laneKeyForModule, resolveLaneLoader} from '../scheduling/lanes'
6
6
  import {orderedTasks, type ScheduleData} from '../scheduling/schedule'
7
- import {HoldKind, TaskType} from '../types'
7
+ import {HoldKind, TaskCancelable, TaskType} from '../types'
8
8
 
9
9
  type CargoItem = ServerContract.Types.cargo_item
10
10
  type Lane = ServerContract.Types.lane
@@ -22,7 +22,6 @@ export interface CivicShuttleBay {
22
22
  full: boolean
23
23
  }
24
24
 
25
- // a landed booking clears during the next civicshuttle, so only unfinished tasks occupy a bay
26
25
  function unfinishedTasks(lane: Lane | undefined, now: Date): number {
27
26
  if (!lane) return 0
28
27
  const startedMs = lane.schedule.started.toDate().getTime()
@@ -64,15 +63,38 @@ export function civicShuttleBays(
64
63
  return bays
65
64
  }
66
65
 
67
- // Mirrors worker_lane_key_or_mobility for loaders: first free bay, else the lowest bay.
66
+ function laneEndMs(lane: Lane | undefined, nowMs: number): number {
67
+ if (!lane) return nowMs
68
+ let endSec = 0
69
+ for (const task of lane.schedule.tasks) endSec += task.duration.toNumber()
70
+ return Math.max(nowMs, lane.schedule.started.toDate().getTime() + endSec * 1000)
71
+ }
72
+
73
+ // Mirrors cargo.cpp pick_civic_bay: earliest finish among bays below the queue cap.
68
74
  export function selectCivicShuttleBay(
69
75
  modules: ModuleEntry[],
70
76
  entityItemId: number,
71
77
  lanes: Lane[],
72
- now: Date = new Date()
78
+ now: Date = new Date(),
79
+ durationFor: (bay: CivicShuttleBay) => number = () => 0,
80
+ minStart?: Date
73
81
  ): CivicShuttleBay | undefined {
74
82
  const bays = civicShuttleBays(modules, entityItemId, lanes, now)
75
- return bays.find((bay) => bay.queued === 0) ?? bays[0]
83
+ const nowMs = now.getTime()
84
+ const floorMs = Math.max(nowMs, minStart?.getTime() ?? nowMs)
85
+ let best: CivicShuttleBay | undefined
86
+ let bestEnd = Infinity
87
+ for (const bay of bays) {
88
+ if (bay.full) continue
89
+ const lane = lanes.find((l) => l.lane_key.toNumber() === bay.laneKey)
90
+ const start = Math.max(laneEndMs(lane, nowMs), floorMs)
91
+ const end = start + durationFor(bay) * 1000
92
+ if (end < bestEnd) {
93
+ best = bay
94
+ bestEnd = end
95
+ }
96
+ }
97
+ return best ?? bays[0]
76
98
  }
77
99
 
78
100
  export interface PendingCivicTransfer {
@@ -98,7 +120,31 @@ export function pendingCivicTransfers(
98
120
  }
99
121
  const out: PendingCivicTransfer[] = []
100
122
  for (const entry of orderedTasks(entity)) {
101
- if (entry.task.type.toNumber() !== TaskType.SHUTTLE) continue
123
+ const type = entry.task.type.toNumber()
124
+ if (type === TaskType.CIVIC_DEPOSIT || type === TaskType.CIVIC_WITHDRAW) {
125
+ const side = entry.task.couplings[0]
126
+ if (!side) continue
127
+ const isTail = tailIndex.get(entry.laneKey) === entry.taskIndex
128
+ const complete = entry.completesAt.getTime() <= now.getTime()
129
+ out.push({
130
+ laneKey: entry.laneKey,
131
+ taskIndex: entry.taskIndex,
132
+ senderId: side.counterpart.entity_id,
133
+ receiverId: side.counterpart.entity_id,
134
+ cargo: entry.task.cargo,
135
+ startsAt: entry.startsAt,
136
+ completesAt: entry.completesAt,
137
+ isTail,
138
+ complete,
139
+ callable:
140
+ isTail &&
141
+ !complete &&
142
+ !entry.task.entitygroup &&
143
+ entry.task.cancelable.toNumber() !== TaskCancelable.NEVER,
144
+ })
145
+ continue
146
+ }
147
+ if (type !== TaskType.SHUTTLE) continue
102
148
  const pull = entry.task.couplings.find((c) => c.kind.toNumber() === HoldKind.PULL)
103
149
  const push = entry.task.couplings.find((c) => c.kind.toNumber() === HoldKind.PUSH)
104
150
  if (!pull || !push) continue
@@ -1,9 +1,4 @@
1
- import type {NameType} from '@wharfkit/antelope'
2
- import type {ServerContract} from '../contracts'
3
- import {getEntityClass} from '../data/kind-registry'
4
1
  import {computeLoaderMass, computeLoaderThrust} from '../nft/description'
5
- import {laneKeyForModule, resolveLaneLoader} from '../scheduling/lanes'
6
- import {calc_onesided_duration} from '../travel/travel'
7
2
  import {MASS_STAT_SCALE, MASS_UNITS_PER_TONNE, PRECISION} from '../types'
8
3
  import {CIVIC_LOADER_STAT, CIVIC_LOADER_TIER, DEFAULT_ORBITAL_Z} from './constants'
9
4
 
@@ -39,63 +34,3 @@ export function contributeDuration(totalMass: number, altitudeZ = 0): number {
39
34
  export function contributeDurationForTonnes(tonnes: number, altitudeZ = 0): number {
40
35
  return contributeDuration(Math.floor(tonnes * MASS_UNITS_PER_TONNE), altitudeZ)
41
36
  }
42
-
43
- export const DEPOT_LOADER_SLOT = 0
44
-
45
- export interface DepotTransferParams {
46
- depotModules: ServerContract.Types.module_entry[]
47
- depotItemId: number
48
- depotKind: NameType
49
- depotZ: number
50
- shipKind: NameType
51
- shipZ: number
52
- cargoMass: number
53
- }
54
-
55
- // Mirrors depot.cpp: the depot's slot-0 loader drives the transfer; 0 when no loader is installed.
56
- export function depotTransferDuration(params: DepotTransferParams): number {
57
- const loader = resolveLaneLoader(
58
- params.depotModules,
59
- params.depotItemId,
60
- laneKeyForModule(DEPOT_LOADER_SLOT)
61
- )
62
- if (!loader.valid) return 0
63
- return calc_onesided_duration(
64
- loader.thrust,
65
- loader.mass,
66
- params.shipZ,
67
- params.depotZ,
68
- getEntityClass(params.shipKind),
69
- getEntityClass(params.depotKind),
70
- params.cargoMass
71
- )
72
- }
73
-
74
- export interface CivicDropoffParams {
75
- buildingModules: ServerContract.Types.module_entry[]
76
- buildingItemId: number
77
- buildingKind: NameType
78
- buildingZ: number
79
- shipKind: NameType
80
- shipZ: number
81
- cargoMass: number
82
- }
83
-
84
- // Mirrors jobs.cpp craftjob: the building's slot-0 loader when one is installed, the civic loader when none is.
85
- export function civicDropoffDuration(params: CivicDropoffParams): number {
86
- const loader = resolveLaneLoader(
87
- params.buildingModules,
88
- params.buildingItemId,
89
- laneKeyForModule(DEPOT_LOADER_SLOT)
90
- )
91
- if (!loader.valid) return contributeDuration(params.cargoMass, params.shipZ)
92
- return calc_onesided_duration(
93
- loader.thrust,
94
- loader.mass,
95
- params.shipZ,
96
- params.buildingZ,
97
- getEntityClass(params.shipKind),
98
- getEntityClass(params.buildingKind),
99
- params.cargoMass
100
- )
101
- }
@@ -26,24 +26,23 @@ export {
26
26
  export {
27
27
  componentBaseAtomic,
28
28
  componentEffortAtomic,
29
+ explainCargoItem,
29
30
  pricingFromWeights,
30
31
  resourceValueAtomic,
31
32
  valueCargoItem,
32
33
  valueContribution,
33
34
  weightedQualityAtomic,
35
+ type CargoValuation,
36
+ type ComponentValuation,
34
37
  type InfluencePricing,
38
+ type ResourceValuation,
35
39
  type ValuedItem,
36
40
  } from './valuation'
37
41
  export {
38
- civicDropoffDuration,
39
42
  civicLoader,
40
43
  contributeDuration,
41
44
  contributeDurationForTonnes,
42
- depotTransferDuration,
43
- DEPOT_LOADER_SLOT,
44
- type CivicDropoffParams,
45
45
  type CivicLoaderStats,
46
- type DepotTransferParams,
47
46
  } from './duration'
48
47
  export {
49
48
  civicShuttleBays,
@@ -1,6 +1,5 @@
1
1
  import {describe, expect, test} from 'bun:test'
2
- import {Checksum256, UInt8, UInt16, UInt64} from '@wharfkit/antelope'
3
- import type {ServerContract} from '../contracts'
2
+ import {Checksum256} from '@wharfkit/antelope'
4
3
  import {ITEM_ORE_T1, ITEM_PLATE, ITEM_CRYSTAL_T1} from '../data/item-ids'
5
4
  import {categoryIndex} from './categories'
6
5
  import {
@@ -20,9 +19,10 @@ import {
20
19
  popcount5,
21
20
  } from './demand'
22
21
  import {findDecomp, DECOMP_REGISTRY} from './decomp'
23
- import {civicDropoffDuration, depotTransferDuration, contributeDuration} from './duration'
22
+ import {contributeDuration} from './duration'
24
23
  import {getStatCount, statsSumSq} from './quality'
25
- import {pricingFromWeights, valueCargoItem, valueContribution} from './valuation'
24
+ import {explainCargoItem, pricingFromWeights, valueCargoItem, valueContribution} from './valuation'
25
+ import {getItems} from '../data/catalog'
26
26
  import {citizenryName, citizenryPatternCount} from './citizenry'
27
27
  import citizenryAdjectives from '../data/citizenry-adjectives.json'
28
28
  import citizenryNouns from '../data/citizenry-nouns.json'
@@ -155,6 +155,53 @@ describe('decomposition', () => {
155
155
  })
156
156
  })
157
157
 
158
+ describe('explainCargoItem', () => {
159
+ test('factors reproduce valueCargoItem for every valuable catalog item', () => {
160
+ const acute = buildDemand(0, categoryIndex('regolith'), DEMAND_TRIPLE_SEED)
161
+ for (const def of getItems()) {
162
+ if (def.type !== 'resource' && def.type !== 'component') continue
163
+ const item = {itemId: Number(def.id), quantity: 7, stats: packStats(363, 535, 246)}
164
+ const explained = explainCargoItem(item, acute, pricing)
165
+ expect(explained.totalAtomic).toBe(valueCargoItem(item, acute, pricing))
166
+ }
167
+ })
168
+
169
+ test('a resource explains mass, weight, quality and need', () => {
170
+ const acute = buildDemand(0, categoryIndex('crystal'), DEMAND_TRIPLE_SEED)
171
+ const explained = explainCargoItem(
172
+ {itemId: ITEM_CRYSTAL_T1, quantity: 5000, stats: packStats(PAR)},
173
+ acute,
174
+ pricing
175
+ )
176
+ if (explained.kind !== 'resource') throw new Error('expected resource')
177
+ expect(explained.mass).toBe(
178
+ BigInt(getItems().find((i) => Number(i.id) === ITEM_CRYSTAL_T1)!.mass) * 5000n
179
+ )
180
+ expect(explained.weight).toBe(1)
181
+ expect(explained.quality).toBeCloseTo(1, 6)
182
+ expect(explained.need).toBe(2)
183
+ expect(explained.points).toBeCloseTo(
184
+ (Number(explained.mass) / 10) * explained.weight * explained.quality * explained.need,
185
+ 3
186
+ )
187
+ })
188
+
189
+ test('a component explains raw mass, material weight, quality and effort', () => {
190
+ const explained = explainCargoItem(
191
+ {itemId: ITEM_PLATE, quantity: 3, stats: packStats(PAR)},
192
+ baseDemand,
193
+ pricing
194
+ )
195
+ if (explained.kind !== 'component') throw new Error('expected component')
196
+ const entry = findDecomp(ITEM_PLATE)!
197
+ const raw = entry.buckets.reduce((sum, b) => sum + b.rawMass, 0)
198
+ expect(explained.rawMass).toBe(raw * 3)
199
+ expect(explained.processedMass).toBe(entry.processedMass * 3)
200
+ expect(explained.weight).toBeGreaterThanOrEqual(1)
201
+ expect(explained.baseAtomic + explained.effortAtomic).toBe(explained.totalAtomic)
202
+ })
203
+ })
204
+
158
205
  describe('valuation', () => {
159
206
  test('a tonne of par T1 ore at base demand is worth exactly one point', () => {
160
207
  const value = valueCargoItem(
@@ -316,135 +363,3 @@ describe('scales', () => {
316
363
  expect(NEED_FP_SCALE % INFLUENCE_ATOMIC_PER_POINT).toBe(0)
317
364
  })
318
365
  })
319
-
320
- describe('depot transfer duration', () => {
321
- type ModuleEntry = ServerContract.Types.module_entry
322
- const DEPOT_ITEM = 10219
323
- const LOADER_T1 = 10103
324
-
325
- function makeModuleEntry(itemId: number, stats: bigint): ModuleEntry {
326
- return {
327
- type: UInt8.from(0),
328
- installed: {item_id: UInt16.from(itemId), stats: UInt64.from(stats)},
329
- } as unknown as ModuleEntry
330
- }
331
- function emptySlot(): ModuleEntry {
332
- return {type: UInt8.from(0)} as unknown as ModuleEntry
333
- }
334
-
335
- // A spawned depot: loader in slot 0, four empty bays after it.
336
- const depotWithLoader = (): ModuleEntry[] => [
337
- makeModuleEntry(LOADER_T1, packStats(500)),
338
- emptySlot(),
339
- emptySlot(),
340
- emptySlot(),
341
- emptySlot(),
342
- ]
343
- const params = (overrides: Record<string, unknown> = {}) => ({
344
- depotModules: depotWithLoader(),
345
- depotItemId: DEPOT_ITEM,
346
- depotKind: 'depot',
347
- depotZ: 0,
348
- shipKind: 'ship',
349
- shipZ: 0,
350
- cargoMass: 100,
351
- ...overrides,
352
- })
353
-
354
- test('drives off the depot loader, not the ship', () => {
355
- expect(depotTransferDuration(params())).toBeGreaterThan(0)
356
- })
357
-
358
- test('grows with cargo mass', () => {
359
- expect(depotTransferDuration(params({cargoMass: 10_000}))).toBeGreaterThan(
360
- depotTransferDuration(params({cargoMass: 100}))
361
- )
362
- })
363
-
364
- test('empty cargo costs nothing', () => {
365
- expect(depotTransferDuration(params({cargoMass: 0}))).toBe(0)
366
- })
367
-
368
- test('a depot with no loader installed cannot transfer', () => {
369
- expect(depotTransferDuration(params({depotModules: [emptySlot()]}))).toBe(0)
370
- })
371
-
372
- test('co-located transfers are floored at the orbital minimum distance', () => {
373
- // max(planetary 100, orbital 200) = 200, so anything under 200 reads as 200
374
- const touching = depotTransferDuration(params({shipZ: 0, depotZ: 0}))
375
- const under = depotTransferDuration(params({shipZ: 150, depotZ: 0}))
376
- const over = depotTransferDuration(params({shipZ: 400, depotZ: 0}))
377
- expect(under).toBe(touching)
378
- expect(over).toBeGreaterThan(touching)
379
- })
380
-
381
- test('never reports zero for real cargo', () => {
382
- expect(depotTransferDuration(params({cargoMass: 1}))).toBeGreaterThanOrEqual(1)
383
- })
384
- })
385
-
386
- describe('civic drop-off duration', () => {
387
- type ModuleEntry = ServerContract.Types.module_entry
388
- const DEPOT_ITEM = 10219
389
- const WORKSHOP_ITEM = 10208
390
- const LOADER_T1 = 10103
391
-
392
- function makeModuleEntry(itemId: number, stats: bigint): ModuleEntry {
393
- return {
394
- type: UInt8.from(0),
395
- installed: {item_id: UInt16.from(itemId), stats: UInt64.from(stats)},
396
- } as unknown as ModuleEntry
397
- }
398
- function emptySlot(): ModuleEntry {
399
- return {type: UInt8.from(0)} as unknown as ModuleEntry
400
- }
401
-
402
- const withLoader = () => ({
403
- buildingModules: [makeModuleEntry(LOADER_T1, packStats(500)), emptySlot()],
404
- buildingItemId: DEPOT_ITEM,
405
- buildingKind: 'depot',
406
- buildingZ: 0,
407
- shipKind: 'ship',
408
- shipZ: 400,
409
- cargoMass: 100,
410
- })
411
- const withoutLoader = () => ({
412
- buildingModules: [] as ModuleEntry[],
413
- buildingItemId: WORKSHOP_ITEM,
414
- buildingKind: 'workshop',
415
- buildingZ: 0,
416
- shipKind: 'ship',
417
- shipZ: 400,
418
- cargoMass: 100,
419
- })
420
-
421
- test('a building with a slot-0 loader prices off that loader', () => {
422
- const p = withLoader()
423
- expect(civicDropoffDuration(p)).toBe(
424
- depotTransferDuration({
425
- depotModules: p.buildingModules,
426
- depotItemId: p.buildingItemId,
427
- depotKind: p.buildingKind,
428
- depotZ: p.buildingZ,
429
- shipKind: p.shipKind,
430
- shipZ: p.shipZ,
431
- cargoMass: p.cargoMass,
432
- })
433
- )
434
- })
435
-
436
- test('a building with no loader falls back to the civic loader', () => {
437
- const p = withoutLoader()
438
- expect(civicDropoffDuration(p)).toBe(contributeDuration(p.cargoMass, p.shipZ))
439
- expect(civicDropoffDuration(p)).toBeGreaterThan(0)
440
- })
441
-
442
- test('grows with cargo mass on both paths', () => {
443
- expect(civicDropoffDuration({...withLoader(), cargoMass: 10_000})).toBeGreaterThan(
444
- civicDropoffDuration(withLoader())
445
- )
446
- expect(civicDropoffDuration({...withoutLoader(), cargoMass: 10_000})).toBeGreaterThan(
447
- civicDropoffDuration(withoutLoader())
448
- )
449
- })
450
- })
@@ -126,6 +126,81 @@ export function valueCargoItem(
126
126
  return withEffort
127
127
  }
128
128
 
129
+ export interface ResourceValuation {
130
+ kind: 'resource'
131
+ category: number
132
+ tier: number
133
+ mass: bigint
134
+ weight: number
135
+ quality: number
136
+ need: number
137
+ totalAtomic: bigint
138
+ points: number
139
+ }
140
+
141
+ export interface ComponentValuation {
142
+ kind: 'component'
143
+ rawMass: number
144
+ processedMass: number
145
+ weight: number
146
+ quality: number
147
+ baseAtomic: bigint
148
+ effortAtomic: bigint
149
+ totalAtomic: bigint
150
+ points: number
151
+ }
152
+
153
+ export type CargoValuation = ResourceValuation | ComponentValuation
154
+
155
+ export function explainCargoItem(
156
+ item: ValuedItem,
157
+ demand: DemandView,
158
+ pricing: InfluencePricing
159
+ ): CargoValuation {
160
+ const def = getItem(item.itemId)
161
+ const totalAtomic = valueCargoItem(item, demand, pricing)
162
+ const points = Number(totalAtomic) / INFLUENCE_ATOMIC_PER_POINT
163
+ const nStats = getStatCount(item.itemId)
164
+ const quality = statsSumSq(item.stats, nStats) / (nStats * pricing.d1)
165
+
166
+ if (def.type === 'resource') {
167
+ if (def.category === undefined) throw new Error('resource has no category')
168
+ const category = categoryIndex(def.category)
169
+ return {
170
+ kind: 'resource',
171
+ category,
172
+ tier: def.tier,
173
+ mass: BigInt(def.mass) * BigInt(item.quantity),
174
+ weight: Number(pricing.weight(category, def.tier)) / WEIGHT_FP_SCALE,
175
+ quality,
176
+ need: Number(needForCategory(demand, category)) / NEED_FP_SCALE,
177
+ totalAtomic,
178
+ points,
179
+ }
180
+ }
181
+
182
+ const entry = findDecomp(item.itemId)
183
+ if (!entry) throw new Error('item has no decomposition')
184
+ let rawMass = 0
185
+ let weighted = 0n
186
+ for (const bucket of entry.buckets) {
187
+ rawMass += bucket.rawMass
188
+ weighted += BigInt(bucket.rawMass) * pricing.weight(bucket.category, bucket.tier)
189
+ }
190
+ const effortAtomic = componentEffortAtomic(entry.processedMass, item.quantity)
191
+ return {
192
+ kind: 'component',
193
+ rawMass: rawMass * item.quantity,
194
+ processedMass: entry.processedMass * item.quantity,
195
+ weight: rawMass === 0 ? 0 : Number(weighted) / (rawMass * WEIGHT_FP_SCALE),
196
+ quality,
197
+ baseAtomic: totalAtomic - effortAtomic,
198
+ effortAtomic,
199
+ totalAtomic,
200
+ points,
201
+ }
202
+ }
203
+
129
204
  export function valueContribution(
130
205
  bundle: ValuedItem[],
131
206
  demand: DemandView,