@shipload/sdk 1.0.0-next.65 → 1.0.0-next.66

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 (79) hide show
  1. package/lib/shipload.d.ts +698 -316
  2. package/lib/shipload.js +2473 -1235
  3. package/lib/shipload.js.map +1 -1
  4. package/lib/shipload.m.js +2392 -1216
  5. package/lib/shipload.m.js.map +1 -1
  6. package/lib/testing.d.ts +205 -146
  7. package/lib/testing.js +846 -556
  8. package/lib/testing.js.map +1 -1
  9. package/lib/testing.m.js +846 -556
  10. package/lib/testing.m.js.map +1 -1
  11. package/package.json +1 -1
  12. package/src/capabilities/crafting.test.ts +51 -6
  13. package/src/capabilities/crafting.ts +19 -4
  14. package/src/capabilities/gathering.ts +2 -2
  15. package/src/capabilities/storage.test.ts +26 -0
  16. package/src/capabilities/storage.ts +6 -6
  17. package/src/contracts/fund.ts +79 -2
  18. package/src/contracts/platform.ts +10 -1
  19. package/src/contracts/server.ts +467 -331
  20. package/src/data/charters.json +325 -127
  21. package/src/data/entities.json +24 -24
  22. package/src/data/items.json +114 -114
  23. package/src/data/kind-registry.json +3 -3
  24. package/src/data/kind-registry.ts +5 -0
  25. package/src/data/recipes.json +56 -95
  26. package/src/derivation/build-methods.test.ts +59 -1
  27. package/src/derivation/build-methods.ts +2 -0
  28. package/src/derivation/capabilities.ts +7 -7
  29. package/src/derivation/strata.ts +5 -3
  30. package/src/derivation/tiers.ts +5 -5
  31. package/src/entity/hydrate.test.ts +156 -0
  32. package/src/entity/hydrate.ts +86 -0
  33. package/src/errors.ts +9 -0
  34. package/src/format.ts +15 -13
  35. package/src/index-module.ts +32 -5
  36. package/src/influence/ballot.test.ts +103 -0
  37. package/src/influence/ballot.ts +98 -0
  38. package/src/influence/charter-metadata.test.ts +136 -0
  39. package/src/influence/charter-metadata.ts +236 -0
  40. package/src/influence/charters.test.ts +51 -65
  41. package/src/influence/charters.ts +70 -40
  42. package/src/influence/civic.test.ts +278 -0
  43. package/src/influence/civic.ts +269 -0
  44. package/src/influence/constants.ts +30 -9
  45. package/src/influence/decomp.ts +15 -15
  46. package/src/influence/duration.ts +42 -6
  47. package/src/influence/index.ts +49 -6
  48. package/src/influence/influence.test.ts +76 -9
  49. package/src/influence/valuation.ts +17 -17
  50. package/src/managers/actions.ts +65 -37
  51. package/src/managers/base.ts +4 -0
  52. package/src/managers/context.ts +3 -1
  53. package/src/managers/index.ts +4 -2
  54. package/src/managers/influence.test.ts +18 -20
  55. package/src/managers/influence.ts +137 -84
  56. package/src/managers/jobs.test.ts +2 -2
  57. package/src/managers/jobs.ts +4 -4
  58. package/src/nft/description.ts +10 -11
  59. package/src/planner/index.ts +54 -0
  60. package/src/planner/planner.test.ts +127 -2
  61. package/src/resolution/display-name.ts +1 -1
  62. package/src/scheduling/availability.test.ts +113 -0
  63. package/src/scheduling/availability.ts +48 -8
  64. package/src/scheduling/jobs.ts +1 -1
  65. package/src/scheduling/projection.ts +2 -16
  66. package/src/scheduling/task-cargo.test.ts +62 -1
  67. package/src/scheduling/task-cargo.ts +25 -10
  68. package/src/scheduling/task-effects.test.ts +35 -0
  69. package/src/scheduling/task-effects.ts +34 -0
  70. package/src/scheduling/unwrap.test.ts +3 -3
  71. package/src/scheduling/unwrap.ts +3 -3
  72. package/src/shipload.ts +22 -3
  73. package/src/travel/reach.test.ts +86 -0
  74. package/src/travel/reach.ts +23 -10
  75. package/src/travel/route-build.test.ts +63 -0
  76. package/src/travel/route-build.ts +60 -0
  77. package/src/travel/travel.ts +2 -1
  78. package/src/types.ts +6 -2
  79. package/src/utils/hash.ts +18 -2
@@ -1,8 +1,11 @@
1
1
  import {UInt8, UInt16, UInt32, UInt64} from '@wharfkit/antelope'
2
2
  import {expect, test, describe} from 'bun:test'
3
3
  import {ServerContract} from '../contracts'
4
+ import {encodeStats} from '../derivation/crafting'
5
+ import {ITEM_GATHERER_T1} from '../data/item-ids'
4
6
  import {
5
7
  planParallelTransfer,
8
+ planSingleGather,
6
9
  type GatherPlanEntity,
7
10
  gatherEnergyCost,
8
11
  splitCost,
@@ -10,6 +13,19 @@ import {
10
13
  buildGatherPlan,
11
14
  } from './index'
12
15
 
16
+ type ModuleEntry = ServerContract.Types.module_entry
17
+ type Lane = ServerContract.Types.lane
18
+
19
+ function makeModuleEntry(itemId: number, stats: bigint): ModuleEntry {
20
+ return {
21
+ type: UInt8.from(0),
22
+ installed: {
23
+ item_id: UInt16.from(itemId),
24
+ stats: UInt64.from(stats),
25
+ },
26
+ } as unknown as ModuleEntry
27
+ }
28
+
13
29
  function gathererLane(
14
30
  slotIndex: number,
15
31
  yieldVal: number,
@@ -114,7 +130,7 @@ describe('planParallelTransfer', () => {
114
130
 
115
131
  describe('cost helpers', () => {
116
132
  const STRATUM = 100
117
- const ITEM_MASS = 1000
133
+ const ITEM_MASS = 10
118
134
  const RICHNESS = 500
119
135
 
120
136
  test('gatherEnergyCost is 0 for non-positive quantity and positive otherwise', () => {
@@ -152,7 +168,7 @@ describe('buildGatherPlan', () => {
152
168
  const STRATUM = 100
153
169
  const OPTS = {
154
170
  richness: 500,
155
- itemMass: 1000,
171
+ itemMass: 10,
156
172
  holdRoom: 1_000_000,
157
173
  reserveRemaining: 1_000_000,
158
174
  now: NOW,
@@ -332,3 +348,112 @@ describe('buildGatherPlan', () => {
332
348
  expect(plan.cycles[0].rechargeSeconds).toBe(0)
333
349
  })
334
350
  })
351
+
352
+ describe('planSingleGather', () => {
353
+ const STRATUM = 100
354
+ const ITEM_MASS = 1000
355
+ const RICHNESS = 500
356
+ const deepStats = encodeStats([100, 900, 100]) // tol=900 => depth=5000 for tier1
357
+
358
+ test('mirrors the bot hand-assembly: laneKey, quantity, energyCost match selectGatherLane + maxQtyForCharge + splitCost', () => {
359
+ const modules: ModuleEntry[] = [makeModuleEntry(ITEM_GATHERER_T1, deepStats)]
360
+ const lane = gathererLane(0, 700, 1_250_000, 5000)
361
+
362
+ const result = planSingleGather({
363
+ modules,
364
+ entityItemId: ITEM_GATHERER_T1,
365
+ lanes: [],
366
+ gathererLanes: [lane],
367
+ stratum: STRATUM,
368
+ desiredQuantity: 100,
369
+ itemMass: ITEM_MASS,
370
+ richness: RICHNESS,
371
+ generatorCapacity: 1_000_000,
372
+ currentEnergy: 1_000_000,
373
+ })
374
+
375
+ const expectedLaneKey = 1
376
+ const expectedQuantity = maxQtyForCharge(
377
+ [lane],
378
+ 100,
379
+ 1_000_000,
380
+ STRATUM,
381
+ ITEM_MASS,
382
+ RICHNESS
383
+ )
384
+ const expectedCost = splitCost([lane], expectedQuantity, STRATUM, ITEM_MASS, RICHNESS)
385
+
386
+ expect(result.laneKey).toBe(expectedLaneKey)
387
+ expect(result.quantity).toBe(expectedQuantity)
388
+ expect(result.energyCost).toBe(expectedCost)
389
+ expect(result.needsRecharge).toBe(false)
390
+ })
391
+
392
+ test('needsRecharge is true when current energy is below the priced cost', () => {
393
+ const modules: ModuleEntry[] = [makeModuleEntry(ITEM_GATHERER_T1, deepStats)]
394
+ const lane = gathererLane(0, 700, 1_250_000, 5000)
395
+
396
+ const result = planSingleGather({
397
+ modules,
398
+ entityItemId: ITEM_GATHERER_T1,
399
+ lanes: [],
400
+ gathererLanes: [lane],
401
+ stratum: STRATUM,
402
+ desiredQuantity: 100,
403
+ itemMass: ITEM_MASS,
404
+ richness: RICHNESS,
405
+ generatorCapacity: 1_000_000,
406
+ currentEnergy: 0,
407
+ })
408
+
409
+ expect(result.energyCost).toBeGreaterThan(0)
410
+ expect(result.needsRecharge).toBe(true)
411
+ })
412
+
413
+ test('caps quantity to what fits one full charge, same boundary as maxQtyForCharge', () => {
414
+ const modules: ModuleEntry[] = [makeModuleEntry(ITEM_GATHERER_T1, deepStats)]
415
+ const lane = gathererLane(0, 700, 1_250_000, 5000)
416
+ const capacity = 40_000
417
+
418
+ const result = planSingleGather({
419
+ modules,
420
+ entityItemId: ITEM_GATHERER_T1,
421
+ lanes: [],
422
+ gathererLanes: [lane],
423
+ stratum: STRATUM,
424
+ desiredQuantity: 100_000,
425
+ itemMass: ITEM_MASS,
426
+ richness: RICHNESS,
427
+ generatorCapacity: capacity,
428
+ currentEnergy: capacity,
429
+ })
430
+
431
+ expect(
432
+ splitCost([lane], result.quantity, STRATUM, ITEM_MASS, RICHNESS)
433
+ ).toBeLessThanOrEqual(capacity)
434
+ expect(
435
+ splitCost([lane], result.quantity + 1, STRATUM, ITEM_MASS, RICHNESS)
436
+ ).toBeGreaterThan(capacity)
437
+ })
438
+
439
+ test('throws when no gatherer lane reaches the stratum, same as selectGatherLane', () => {
440
+ const shallowStats = encodeStats([100, 10, 100]) // depth=550 for tier1
441
+ const modules: ModuleEntry[] = [makeModuleEntry(ITEM_GATHERER_T1, shallowStats)]
442
+ const lane = gathererLane(0, 700, 1_250_000, 550)
443
+
444
+ expect(() =>
445
+ planSingleGather({
446
+ modules,
447
+ entityItemId: ITEM_GATHERER_T1,
448
+ lanes: [] as Lane[],
449
+ gathererLanes: [lane],
450
+ stratum: 5000,
451
+ desiredQuantity: 100,
452
+ itemMass: ITEM_MASS,
453
+ richness: RICHNESS,
454
+ generatorCapacity: 1_000_000,
455
+ currentEnergy: 1_000_000,
456
+ })
457
+ ).toThrow('no gatherer reaches this stratum')
458
+ })
459
+ })
@@ -50,7 +50,7 @@ export function displayName(item: DisplayNameInput): string {
50
50
  export interface DescribeOptions {
51
51
  translate?: (key: string) => string
52
52
  formatNumber?: (n: number) => string
53
- formatMass?: (kg: number) => string
53
+ formatMass?: (mass: number) => string
54
54
  }
55
55
 
56
56
  export function describeItem(resolved: ResolvedItem, opts?: DescribeOptions): string {
@@ -6,6 +6,7 @@ import {
6
6
  type CargoInput,
7
7
  hasIncomingCoupling,
8
8
  hasSourceCoupling,
9
+ incomingSources,
9
10
  type IncomingSource,
10
11
  projectedCargoAvailableAt,
11
12
  taskCargoEffect,
@@ -202,3 +203,115 @@ describe('clustercraft projection parity', () => {
202
203
  expect(eff.added).toEqual([output])
203
204
  })
204
205
  })
206
+
207
+ function simpleTask(type: number) {
208
+ return ServerContract.Types.task.from({
209
+ type,
210
+ duration: 60,
211
+ cancelable: 0,
212
+ cargo: [cargoItem(401, 0, 100)],
213
+ couplings: [],
214
+ })
215
+ }
216
+
217
+ describe('taskCargoEffect task coverage', () => {
218
+ test('a contribute removes its cargo', () => {
219
+ const effect = taskCargoEffect(simpleTask(TaskType.CONTRIBUTE))
220
+ expect(effect.removed).toHaveLength(1)
221
+ expect(effect.added).toHaveLength(0)
222
+ })
223
+
224
+ test('a depot store removes its cargo', () => {
225
+ const effect = taskCargoEffect(simpleTask(TaskType.DEPOT_STORE))
226
+ expect(effect.removed).toHaveLength(1)
227
+ expect(effect.added).toHaveLength(0)
228
+ })
229
+
230
+ test('a depot take adds its cargo', () => {
231
+ const effect = taskCargoEffect(simpleTask(TaskType.DEPOT_TAKE))
232
+ expect(effect.added).toHaveLength(1)
233
+ expect(effect.removed).toHaveLength(0)
234
+ })
235
+ })
236
+
237
+ describe('incomingSources', () => {
238
+ const RECEIVER = 1
239
+ const SENDER = 2
240
+
241
+ function receiver(holds: {id: number; kind: number; from: number; until: string}[]) {
242
+ const e = entity([])
243
+ return ServerContract.Types.entity_info.from({
244
+ ...JSON.parse(JSON.stringify(e)),
245
+ id: RECEIVER,
246
+ holds: holds.map((h) => ({
247
+ id: h.id,
248
+ kind: h.kind,
249
+ counterpart: {entity_type: 'ship', entity_id: h.from},
250
+ until: h.until,
251
+ incoming_mass: 100,
252
+ })),
253
+ })
254
+ }
255
+
256
+ function sender(tasks: ReturnType<typeof craftTask>[]) {
257
+ const e = entity([], [])
258
+ return ServerContract.Types.entity_info.from({
259
+ ...JSON.parse(JSON.stringify(e)),
260
+ id: SENDER,
261
+ lanes: [{lane_key: 0, schedule: {started: T0, tasks}}],
262
+ })
263
+ }
264
+
265
+ function coupledTo(hold: number, entityId: number, kind: number) {
266
+ return ServerContract.Types.coupling.from({
267
+ counterpart: {entity_type: 'ship', entity_id: entityId},
268
+ hold,
269
+ kind,
270
+ })
271
+ }
272
+
273
+ test('a coupled craft output resolves to the receiving hold', () => {
274
+ const to = receiver([{id: 1, kind: HoldKind.GATHER, from: SENDER, until: T0}])
275
+ const from = sender([
276
+ craftTask(
277
+ [cargoItem(7, 0, 4), cargoItem(9, 0, 1)],
278
+ [coupledTo(1, RECEIVER, HoldKind.GATHER)]
279
+ ),
280
+ ])
281
+ const sources = incomingSources(to, () => from)
282
+ expect(sources).toHaveLength(1)
283
+ // A craft delivers its output slot alone, never its inputs.
284
+ expect(sources[0].items).toHaveLength(1)
285
+ expect(sources[0].items[0].item_id.toNumber()).toBe(9)
286
+ expect(sources[0].holdId).toBe('1')
287
+ })
288
+
289
+ test('a hold whose counterpart is unknown yields nothing', () => {
290
+ const to = receiver([{id: 1, kind: HoldKind.GATHER, from: SENDER, until: T0}])
291
+ expect(incomingSources(to, () => undefined)).toHaveLength(0)
292
+ })
293
+
294
+ test('an excluded hold is skipped', () => {
295
+ const to = receiver([{id: 1, kind: HoldKind.GATHER, from: SENDER, until: T0}])
296
+ const from = sender([
297
+ craftTask([cargoItem(9, 0, 1)], [coupledTo(1, RECEIVER, HoldKind.GATHER)]),
298
+ ])
299
+ expect(incomingSources(to, () => from, ['1'])).toHaveLength(0)
300
+ })
301
+
302
+ test('a coupling naming another hold does not fill this one', () => {
303
+ const to = receiver([{id: 1, kind: HoldKind.GATHER, from: SENDER, until: T0}])
304
+ const from = sender([
305
+ craftTask([cargoItem(9, 0, 1)], [coupledTo(2, RECEIVER, HoldKind.GATHER)]),
306
+ ])
307
+ expect(incomingSources(to, () => from)).toHaveLength(0)
308
+ })
309
+
310
+ test('an outgoing hold kind is not an incoming source', () => {
311
+ const to = receiver([{id: 1, kind: HoldKind.PULL, from: SENDER, until: T0}])
312
+ const from = sender([
313
+ craftTask([cargoItem(9, 0, 1)], [coupledTo(1, RECEIVER, HoldKind.GATHER)]),
314
+ ])
315
+ expect(incomingSources(to, () => from)).toHaveLength(0)
316
+ })
317
+ })
@@ -1,6 +1,7 @@
1
1
  import type {ServerContract} from '../contracts'
2
2
  import {HoldKind, TaskType} from '../types'
3
3
  import * as schedule from './schedule'
4
+ import {taskCargoRule} from './task-effects'
4
5
 
5
6
  type Task = ServerContract.Types.task
6
7
  type Coupling = ServerContract.Types.coupling
@@ -64,20 +65,59 @@ export function calcCounterpartDelivery(task: Task, coupling: Coupling): CargoIt
64
65
  return task.cargo
65
66
  }
66
67
 
68
+ export interface IncomingHoldEntity extends schedule.ScheduleData {
69
+ id: {toString(): string}
70
+ holds?: ServerContract.Types.hold[]
71
+ }
72
+
73
+ // Mirrors incoming_sources: each incoming hold resolved through the counterpart task that will fill it.
74
+ export function incomingSources(
75
+ entity: IncomingHoldEntity,
76
+ findCounterpart: (entityId: string) => schedule.ScheduleData | undefined,
77
+ excludedHoldIds: readonly string[] = []
78
+ ): IncomingSource[] {
79
+ const out: IncomingSource[] = []
80
+ const excluded = new Set(excludedHoldIds.map((id) => String(id)))
81
+ const selfId = entity.id.toString()
82
+ for (const hold of entity.holds ?? []) {
83
+ if (!isIncomingCouplingKind(hold.kind.toNumber())) continue
84
+ const holdId = hold.id.toString()
85
+ if (excluded.has(holdId)) continue
86
+ const counterpart = findCounterpart(hold.counterpart.entity_id.toString())
87
+ if (!counterpart) continue
88
+ let matched = false
89
+ for (const lane of counterpart.lanes ?? []) {
90
+ if (matched) break
91
+ for (const task of lane.schedule.tasks) {
92
+ if (matched) break
93
+ for (const coupling of task.couplings) {
94
+ if (coupling.hold.toString() !== holdId) continue
95
+ if (coupling.counterpart.entity_id.toString() !== selfId) continue
96
+ const items = calcCounterpartDelivery(task, coupling)
97
+ if (items.length > 0) {
98
+ out.push({holdId, until: hold.until.toDate(), items})
99
+ }
100
+ matched = true
101
+ break
102
+ }
103
+ }
104
+ }
105
+ }
106
+ return out
107
+ }
108
+
67
109
  export function taskCargoEffect(task: Task): CargoEffect {
68
- switch (task.type.toNumber()) {
69
- case TaskType.LOAD:
70
- case TaskType.UNWRAP:
71
- case TaskType.UNDEPLOY:
110
+ switch (taskCargoRule(task.type.toNumber())) {
111
+ case 'all-in':
112
+ case 'undeploy':
72
113
  return {added: task.cargo, removed: []}
73
- case TaskType.UNLOAD:
74
- case TaskType.UPGRADE:
114
+ case 'all-out':
75
115
  return {added: [], removed: task.cargo}
76
- case TaskType.GATHER:
116
+ case 'gather':
77
117
  return task.couplings.length > 0
78
118
  ? {added: [], removed: []}
79
119
  : {added: task.cargo, removed: []}
80
- case TaskType.CRAFT: {
120
+ case 'craft': {
81
121
  if (task.cargo.length === 0) return {added: [], removed: []}
82
122
  const {clustered, ownOutput} = craftCargoOwnership(task)
83
123
  return {
@@ -92,7 +92,7 @@ export function splitJobCargo<T>(cargo: readonly T[]): {output: T | null; inputs
92
92
 
93
93
  export interface OwnedJob {
94
94
  id: number
95
- workshop: number
95
+ building: number
96
96
  socket: number
97
97
  shipId: number
98
98
  coords: {x: number; y: number}
@@ -29,6 +29,7 @@ import {
29
29
  subtractFromStacks,
30
30
  stackToCargoItem,
31
31
  } from '../capabilities/storage'
32
+ import {hydrateEntityLanes} from '../entity/hydrate'
32
33
  import {craftCargoOwnership} from './availability'
33
34
  import * as schedule from './schedule'
34
35
  import type {ScheduleData} from './schedule'
@@ -142,21 +143,6 @@ function recomputeCaps(entity: Projectable): ProjectedCaps | undefined {
142
143
  output_pct: l.outputPct,
143
144
  })
144
145
 
145
- const toGathererLane = (l: {
146
- slotIndex: number
147
- yield: number
148
- drain: number
149
- depth: number
150
- outputPct: number
151
- }): ServerContract.Types.gatherer_lane =>
152
- ServerContract.Types.gatherer_lane.from({
153
- slot_index: l.slotIndex,
154
- yield: l.yield,
155
- drain: l.drain,
156
- depth: l.depth,
157
- output_pct: l.outputPct,
158
- })
159
-
160
146
  const toCrafterLane = (l: {
161
147
  slotIndex: number
162
148
  speed: number
@@ -191,7 +177,7 @@ function recomputeCaps(entity: Projectable): ProjectedCaps | undefined {
191
177
  ? ServerContract.Types.energy_stats.from(caps.generator)
192
178
  : undefined,
193
179
  loaderLanes: (caps.loaderLanes ?? []).map(toLoaderLane),
194
- gathererLanes: (caps.gathererLanes ?? []).map(toGathererLane),
180
+ gathererLanes: hydrateEntityLanes(caps),
195
181
  crafterLanes: (caps.crafterLanes ?? []).map(toCrafterLane),
196
182
  builderLanes: (caps.builderLanes ?? []).map(toBuilderLane),
197
183
  hauler: caps.hauler
@@ -1,6 +1,6 @@
1
1
  import {describe, expect, test} from 'bun:test'
2
2
  import {ServerContract, TaskType} from '../index-module'
3
- import {taskCargoChanges} from './task-cargo'
3
+ import {taskCargoChanges, taskCargoChangesChecked} from './task-cargo'
4
4
 
5
5
  function cargoItem(itemId: number, quantity: number) {
6
6
  return ServerContract.Types.cargo_item.from({item_id: itemId, stats: 0, modules: [], quantity})
@@ -42,3 +42,64 @@ describe('taskCargoChanges', () => {
42
42
  ])
43
43
  })
44
44
  })
45
+
46
+ describe('TaskType', () => {
47
+ test('covers the depot task types the contract defines', () => {
48
+ expect(TaskType.DEPOT_STORE).toBe(21)
49
+ expect(TaskType.DEPOT_TAKE).toBe(22)
50
+ })
51
+ })
52
+
53
+ function simpleTask(type: number) {
54
+ return ServerContract.Types.task.from({
55
+ type,
56
+ duration: 60,
57
+ cancelable: 0,
58
+ cargo: [cargoItem(401, 100)],
59
+ couplings: [],
60
+ })
61
+ }
62
+
63
+ describe('taskCargoChanges task coverage', () => {
64
+ test('a depot store moves cargo out of the entity', () => {
65
+ const changes = taskCargoChanges(simpleTask(TaskType.DEPOT_STORE))
66
+ expect(changes).toHaveLength(1)
67
+ expect(changes[0].direction).toBe('out')
68
+ })
69
+
70
+ test('a depot take moves cargo into the entity', () => {
71
+ const changes = taskCargoChanges(simpleTask(TaskType.DEPOT_TAKE))
72
+ expect(changes).toHaveLength(1)
73
+ expect(changes[0].direction).toBe('in')
74
+ })
75
+
76
+ test('an upgrade consumes its cargo', () => {
77
+ const changes = taskCargoChanges(simpleTask(TaskType.UPGRADE))
78
+ expect(changes).toHaveLength(1)
79
+ expect(changes[0].direction).toBe('out')
80
+ })
81
+
82
+ test('an undeploy returns cargo to the entity', () => {
83
+ const changes = taskCargoChanges(simpleTask(TaskType.UNDEPLOY))
84
+ expect(changes).toHaveLength(1)
85
+ expect(changes[0].direction).toBe('in')
86
+ })
87
+ })
88
+
89
+ describe('taskCargoChangesChecked', () => {
90
+ test('reports a known task type with its changes', () => {
91
+ const result = taskCargoChangesChecked(simpleTask(TaskType.DEPOT_TAKE))
92
+ expect(result.known).toBe(true)
93
+ if (result.known) expect(result.changes[0].direction).toBe('in')
94
+ })
95
+
96
+ test('distinguishes an unrecognised task type from one that moves nothing', () => {
97
+ const unknown = taskCargoChangesChecked(simpleTask(99))
98
+ expect(unknown.known).toBe(false)
99
+ if (!unknown.known) expect(unknown.taskType).toBe(99)
100
+
101
+ const travel = taskCargoChangesChecked(simpleTask(TaskType.TRAVEL))
102
+ expect(travel.known).toBe(true)
103
+ if (travel.known) expect(travel.changes).toHaveLength(0)
104
+ })
105
+ })
@@ -1,5 +1,5 @@
1
1
  import type {ServerContract} from '../contracts'
2
- import {TaskType} from '../types'
2
+ import {type TaskCargoRule, taskCargoRule} from './task-effects'
3
3
 
4
4
  export type TaskCargoDirection = 'in' | 'out'
5
5
 
@@ -11,6 +11,10 @@ export interface TaskCargoChange {
11
11
  quantity: number
12
12
  }
13
13
 
14
+ export type TaskCargoResult =
15
+ | {known: true; changes: TaskCargoChange[]}
16
+ | {known: false; taskType: number}
17
+
14
18
  function toChange(
15
19
  item: ServerContract.Types.cargo_item,
16
20
  direction: TaskCargoDirection
@@ -24,19 +28,18 @@ function toChange(
24
28
  }
25
29
  }
26
30
 
27
- export function taskCargoChanges(task: ServerContract.Types.task): TaskCargoChange[] {
31
+ function changesForRule(rule: TaskCargoRule, task: ServerContract.Types.task): TaskCargoChange[] {
28
32
  const items = task.cargo ?? []
29
33
  if (items.length === 0) return []
30
- switch (Number(task.type)) {
31
- case TaskType.LOAD:
32
- case TaskType.UNWRAP:
34
+ switch (rule) {
35
+ case 'all-in':
36
+ case 'undeploy':
33
37
  return items.map((i) => toChange(i, 'in'))
34
- case TaskType.GATHER:
35
- return task.couplings.length > 0 ? [] : items.map((i) => toChange(i, 'in'))
36
- case TaskType.UNLOAD:
37
- case TaskType.CONTRIBUTE:
38
+ case 'all-out':
38
39
  return items.map((i) => toChange(i, 'out'))
39
- case TaskType.CRAFT:
40
+ case 'gather':
41
+ return task.couplings.length > 0 ? [] : items.map((i) => toChange(i, 'in'))
42
+ case 'craft':
40
43
  return [
41
44
  ...items.slice(0, -1).map((i) => toChange(i, 'out')),
42
45
  ...(task.couplings.length > 0 ? [] : [toChange(items[items.length - 1], 'in')]),
@@ -45,3 +48,15 @@ export function taskCargoChanges(task: ServerContract.Types.task): TaskCargoChan
45
48
  return []
46
49
  }
47
50
  }
51
+
52
+ export function taskCargoChanges(task: ServerContract.Types.task): TaskCargoChange[] {
53
+ const rule = taskCargoRule(Number(task.type))
54
+ return rule ? changesForRule(rule, task) : []
55
+ }
56
+
57
+ export function taskCargoChangesChecked(task: ServerContract.Types.task): TaskCargoResult {
58
+ const type = Number(task.type)
59
+ const rule = taskCargoRule(type)
60
+ if (!rule) return {known: false, taskType: type}
61
+ return {known: true, changes: changesForRule(rule, task)}
62
+ }
@@ -0,0 +1,35 @@
1
+ import {describe, expect, test} from 'bun:test'
2
+ import {TaskType} from '../types'
3
+ import {taskCargoRule} from './task-effects'
4
+
5
+ describe('taskCargoRule', () => {
6
+ test('maps the task types that move cargo out of the entity', () => {
7
+ expect(taskCargoRule(TaskType.UNLOAD)).toBe('all-out')
8
+ expect(taskCargoRule(TaskType.UPGRADE)).toBe('all-out')
9
+ expect(taskCargoRule(TaskType.CONTRIBUTE)).toBe('all-out')
10
+ expect(taskCargoRule(TaskType.DEPOT_STORE)).toBe('all-out')
11
+ })
12
+
13
+ test('maps the task types that move cargo into the entity', () => {
14
+ expect(taskCargoRule(TaskType.LOAD)).toBe('all-in')
15
+ expect(taskCargoRule(TaskType.UNWRAP)).toBe('all-in')
16
+ expect(taskCargoRule(TaskType.DEPOT_TAKE)).toBe('all-in')
17
+ })
18
+
19
+ test('maps the conditional task types to their own rules', () => {
20
+ expect(taskCargoRule(TaskType.GATHER)).toBe('gather')
21
+ expect(taskCargoRule(TaskType.CRAFT)).toBe('craft')
22
+ expect(taskCargoRule(TaskType.UNDEPLOY)).toBe('undeploy')
23
+ })
24
+
25
+ test('maps the task types that move no cargo', () => {
26
+ expect(taskCargoRule(TaskType.TRAVEL)).toBe('none')
27
+ expect(taskCargoRule(TaskType.RECHARGE)).toBe('none')
28
+ expect(taskCargoRule(TaskType.DEMOLISH)).toBe('none')
29
+ expect(taskCargoRule(TaskType.BUILDPLOT)).toBe('none')
30
+ })
31
+
32
+ test('returns undefined for a task type the table does not know', () => {
33
+ expect(taskCargoRule(99)).toBeUndefined()
34
+ })
35
+ })
@@ -0,0 +1,34 @@
1
+ import {TaskType} from '../types'
2
+
3
+ export type TaskCargoRule = 'none' | 'all-in' | 'all-out' | 'gather' | 'craft' | 'undeploy'
4
+
5
+ // Transcribed from the switch in contracts/src/server/src/projection.cpp.
6
+ export const TASK_CARGO_EFFECTS: ReadonlyMap<number, TaskCargoRule> = new Map<
7
+ number,
8
+ TaskCargoRule
9
+ >([
10
+ [TaskType.IDLE, 'none'],
11
+ [TaskType.TRAVEL, 'none'],
12
+ [TaskType.RECHARGE, 'none'],
13
+ [TaskType.WARP, 'none'],
14
+ [TaskType.TRANSIT, 'none'],
15
+ [TaskType.LAUNCH, 'none'],
16
+ [TaskType.DEMOLISH, 'none'],
17
+ [TaskType.BUILDPLOT, 'none'],
18
+ [TaskType.CHARGE, 'none'],
19
+ [TaskType.SHUTTLE, 'none'],
20
+ [TaskType.LOAD, 'all-in'],
21
+ [TaskType.UNWRAP, 'all-in'],
22
+ [TaskType.DEPOT_TAKE, 'all-in'],
23
+ [TaskType.UNLOAD, 'all-out'],
24
+ [TaskType.UPGRADE, 'all-out'],
25
+ [TaskType.CONTRIBUTE, 'all-out'],
26
+ [TaskType.DEPOT_STORE, 'all-out'],
27
+ [TaskType.GATHER, 'gather'],
28
+ [TaskType.CRAFT, 'craft'],
29
+ [TaskType.UNDEPLOY, 'undeploy'],
30
+ ])
31
+
32
+ export function taskCargoRule(taskType: number): TaskCargoRule | undefined {
33
+ return TASK_CARGO_EFFECTS.get(taskType)
34
+ }
@@ -20,9 +20,9 @@ describe('unwrap duration mirror', () => {
20
20
  })
21
21
 
22
22
  test('transit floors distance then flight time', () => {
23
- // distance = floor(sqrt(3^2+4^2)*10000)=50000; accel=400/mass*10000; flight=floor(2*sqrt(d/accel))
23
+ // distance = floor(sqrt(3^2+4^2)*10000)=50000; accel=400/(mass*100)*10000; flight=floor(2*sqrt(d/accel))
24
24
  const mass = 1000
25
- const accel = (400 / mass) * 10000
25
+ const accel = (400 / (mass * 100)) * 10000
26
26
  const expected = Math.floor(2 * Math.sqrt(50000 / accel))
27
27
  expect(unwrapTransitDuration(mass, {x: 0, y: 0}, {x: 3, y: 4})).toBe(expected)
28
28
  })
@@ -30,7 +30,7 @@ describe('unwrap duration mirror', () => {
30
30
  test('load uses altitude z, adds loader mass, divides by quantity', () => {
31
31
  const loaders = {mass: 1200, thrust: 30, quantity: 2}
32
32
  const itemMass = 800
33
- const accel = (30 / (itemMass + 1200)) * 10000
33
+ const accel = (30 / ((itemMass + 1200) * 100)) * 10000
34
34
  const flight = Math.floor(2 * Math.sqrt(3000 / accel))
35
35
  expect(unwrapLoadDuration(loaders, itemMass, 3000)).toBe(Math.floor(flight / 2))
36
36
  })
@@ -1,13 +1,13 @@
1
1
  import type {UInt16Type, UInt32Type} from '@wharfkit/antelope'
2
2
  import {calcCargoItemMass} from '../capabilities/storage'
3
3
  import type {ServerContract} from '../contracts'
4
- import {PRECISION} from '../types'
4
+ import {MASS_STAT_SCALE, PRECISION} from '../types'
5
5
  import * as sched from './schedule'
6
6
  import {taskCargoEffect} from './availability'
7
7
  import {candidateLaneCompletesAt} from './lanes'
8
8
 
9
9
  const NFT_TRANSIT_THRUST = 400
10
- const BASELINE_LOADER: DerivedLoaders = {mass: 2000, thrust: 1, quantity: 1}
10
+ const BASELINE_LOADER: DerivedLoaders = {mass: 20, thrust: 1, quantity: 1}
11
11
  // ground-level entities (warehouses, z=0) still incur a base orbital climb of load effort
12
12
  const MIN_LOAD_Z = 800
13
13
 
@@ -37,7 +37,7 @@ export function derivedLoaders(
37
37
 
38
38
  function acceleration(thrust: number, mass: number): number {
39
39
  if (mass <= 0) return 0
40
- return (thrust / mass) * PRECISION
40
+ return (thrust / (mass * MASS_STAT_SCALE)) * PRECISION
41
41
  }
42
42
 
43
43
  function flightTime(distance: number, accel: number): number {