@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,5 +1,5 @@
1
- import type {Checksum256Type} from '@wharfkit/antelope'
2
- import type {CoordinatesType} from '../types'
1
+ import {Checksum256, type Checksum256Type} from '@wharfkit/antelope'
2
+ import {Coordinates, type CoordinatesType} from '../types'
3
3
  import {deriveLocationStatic} from '../utils/system'
4
4
  import {deriveLocationSize} from './location-size'
5
5
  import {deriveResourceStats, deriveStratum, type ResourceStats} from './stratum'
@@ -26,9 +26,11 @@ export function deriveStrata(
26
26
  if (size === 0) return []
27
27
 
28
28
  const subtype = Number(loc.subtype)
29
+ const seed = Checksum256.from(epochSeed)
30
+ const at = Coordinates.from(coords)
29
31
  const out: DerivedStratum[] = []
30
32
  for (let i = 0; i < size; i++) {
31
- const s = deriveStratum(epochSeed, coords, i, locType, subtype, size)
33
+ const s = deriveStratum(seed, at, i, locType, subtype, size)
32
34
  if (s.reserve === 0) continue
33
35
  out.push({
34
36
  index: i,
@@ -8,11 +8,11 @@ export interface TierRange {
8
8
  }
9
9
 
10
10
  export const RESERVE_TIERS: Record<ReserveTier, TierRange> = {
11
- small: {min: 3_600_000, max: 14_400_000},
12
- medium: {min: 24_000_000, max: 48_000_000},
13
- large: {min: 96_000_000, max: 168_000_000},
14
- massive: {min: 240_000_000, max: 600_000_000},
15
- motherlode: {min: 960_000_000, max: 2_400_000_000},
11
+ small: {min: 36_000, max: 144_000},
12
+ medium: {min: 240_000, max: 480_000},
13
+ large: {min: 960_000, max: 1_680_000},
14
+ massive: {min: 2_400_000, max: 6_000_000},
15
+ motherlode: {min: 9_600_000, max: 24_000_000},
16
16
  }
17
17
 
18
18
  const SHALLOW_THRESHOLDS = {
@@ -0,0 +1,156 @@
1
+ import {UInt64} from '@wharfkit/antelope'
2
+ import {describe, expect, test} from 'bun:test'
3
+ import {entityToRouteMover, hydrateEntityLanes} from './hydrate'
4
+
5
+ describe('hydrateEntityLanes', () => {
6
+ test('maps camelCase gathererLanes to gatherer_lane with snake_case fields', () => {
7
+ const lanes = hydrateEntityLanes({
8
+ gathererLanes: [
9
+ {slotIndex: 2, yield: 700, drain: 1_250_000, depth: 5000, outputPct: 100},
10
+ ],
11
+ })
12
+ expect(lanes).toHaveLength(1)
13
+ expect(lanes[0].slot_index.toNumber()).toBe(2)
14
+ expect(lanes[0].yield.toNumber()).toBe(700)
15
+ expect(lanes[0].drain.toNumber()).toBe(1_250_000)
16
+ expect(lanes[0].depth.toNumber()).toBe(5000)
17
+ expect(lanes[0].output_pct.toNumber()).toBe(100)
18
+ })
19
+
20
+ test('falls back to a single synthetic lane from caps.gatherer when gathererLanes is absent', () => {
21
+ const lanes = hydrateEntityLanes({gatherer: {yield: 300, drain: 50, depth: 1000}})
22
+ expect(lanes).toHaveLength(1)
23
+ expect(lanes[0].slot_index.toNumber()).toBe(0)
24
+ expect(lanes[0].yield.toNumber()).toBe(300)
25
+ expect(lanes[0].drain.toNumber()).toBe(50)
26
+ expect(lanes[0].depth.toNumber()).toBe(1000)
27
+ expect(lanes[0].output_pct.toNumber()).toBe(100)
28
+ })
29
+
30
+ test('prefers gathererLanes over the caps.gatherer fallback when both are present', () => {
31
+ const lanes = hydrateEntityLanes({
32
+ gathererLanes: [{slotIndex: 1, yield: 10, drain: 20, depth: 30, outputPct: 40}],
33
+ gatherer: {yield: 999, drain: 999, depth: 999},
34
+ })
35
+ expect(lanes).toHaveLength(1)
36
+ expect(lanes[0].slot_index.toNumber()).toBe(1)
37
+ })
38
+
39
+ test('returns an empty array when there is no gatherer capability at all', () => {
40
+ expect(hydrateEntityLanes({})).toEqual([])
41
+ })
42
+
43
+ test('clamps out-of-range and non-integer fields to the contract field bounds', () => {
44
+ const lanes = hydrateEntityLanes({
45
+ gathererLanes: [
46
+ {
47
+ slotIndex: -5,
48
+ yield: 999_999,
49
+ drain: -1,
50
+ depth: 70_000.7,
51
+ outputPct: Number.NaN,
52
+ },
53
+ ],
54
+ })
55
+ expect(lanes[0].slot_index.toNumber()).toBe(0)
56
+ expect(lanes[0].yield.toNumber()).toBe(65_535)
57
+ expect(lanes[0].drain.toNumber()).toBe(0)
58
+ expect(lanes[0].depth.toNumber()).toBe(65_535)
59
+ expect(lanes[0].output_pct.toNumber()).toBe(0)
60
+ })
61
+
62
+ test('clamps a slot index above the uint8 ceiling', () => {
63
+ const lanes = hydrateEntityLanes({
64
+ gathererLanes: [{slotIndex: 300, yield: 0, drain: 0, depth: 0, outputPct: 0}],
65
+ })
66
+ expect(lanes[0].slot_index.toNumber()).toBe(255)
67
+ })
68
+
69
+ test('clamps drain above the uint32 ceiling', () => {
70
+ const lanes = hydrateEntityLanes({
71
+ gathererLanes: [{slotIndex: 0, yield: 0, drain: 5_000_000_000, depth: 0, outputPct: 0}],
72
+ })
73
+ expect(lanes[0].drain.toNumber()).toBe(4_294_967_295)
74
+ })
75
+ })
76
+
77
+ describe('entityToRouteMover', () => {
78
+ test('sums hullmass, cargomass, and loader lane mass into total mass', () => {
79
+ const mover = entityToRouteMover(
80
+ {id: UInt64.from(1), energy: 500, cargomass: 200},
81
+ {
82
+ hullmass: 1000,
83
+ loaderLanes: [
84
+ {slotIndex: 0, mass: 50, thrust: 10, outputPct: 100},
85
+ {slotIndex: 1, mass: 25, thrust: 10, outputPct: 100},
86
+ ],
87
+ }
88
+ )
89
+ expect(mover.mass).toBe(1000 + 200 + 50 + 25)
90
+ expect(mover.energy).toBe(500)
91
+ expect(mover.ref).toEqual({entityType: 'ship', entityId: UInt64.from(1)})
92
+ })
93
+
94
+ test('derives hasMovement from the presence of both engines and a generator', () => {
95
+ const withMovement = entityToRouteMover(
96
+ {id: UInt64.from(1)},
97
+ {
98
+ hullmass: 0,
99
+ engines: {thrust: 100, drain: 10},
100
+ generator: {capacity: 1000, recharge: 10},
101
+ }
102
+ )
103
+ expect(withMovement.hasMovement).toBe(true)
104
+
105
+ const withoutMovement = entityToRouteMover({id: UInt64.from(1)}, {hullmass: 0})
106
+ expect(withoutMovement.hasMovement).toBe(false)
107
+ })
108
+
109
+ test('an explicit hasMovement opt overrides the derived value', () => {
110
+ const mover = entityToRouteMover(
111
+ {id: UInt64.from(1)},
112
+ {
113
+ hullmass: 0,
114
+ engines: {thrust: 100, drain: 10},
115
+ generator: {capacity: 1000, recharge: 10},
116
+ },
117
+ {hasMovement: false}
118
+ )
119
+ expect(mover.hasMovement).toBe(false)
120
+ })
121
+
122
+ test('narrows hauler down to capacity and efficiency, dropping drain and capacityByTier', () => {
123
+ const mover = entityToRouteMover(
124
+ {id: UInt64.from(1)},
125
+ {
126
+ hullmass: 0,
127
+ hauler: {
128
+ capacity: 500,
129
+ efficiency: 80,
130
+ drain: 10,
131
+ capacityByTier: [{tier: 1, capacity: 500}],
132
+ },
133
+ }
134
+ )
135
+ expect(mover.hauler).toEqual({capacity: 500, efficiency: 80})
136
+ })
137
+
138
+ test('defaults energy, priorMobilityEnd, and narrowBarrierEnd to zero when unset', () => {
139
+ const mover = entityToRouteMover({id: UInt64.from(1)}, {hullmass: 0})
140
+ expect(mover.energy).toBe(0)
141
+ expect(mover.priorMobilityEnd).toBe(0)
142
+ expect(mover.narrowBarrierEnd).toBe(0)
143
+ })
144
+
145
+ test('opts override entityType and scheduling fields', () => {
146
+ const mover = entityToRouteMover(
147
+ {id: UInt64.from(7)},
148
+ {hullmass: 0},
149
+ {entityType: 'starbase', priorMobilityEnd: 30, narrowBarrierEnd: 15, allLanesEnd: 45}
150
+ )
151
+ expect(mover.ref).toEqual({entityType: 'starbase', entityId: UInt64.from(7)})
152
+ expect(mover.priorMobilityEnd).toBe(30)
153
+ expect(mover.narrowBarrierEnd).toBe(15)
154
+ expect(mover.allLanesEnd).toBe(45)
155
+ })
156
+ })
@@ -0,0 +1,86 @@
1
+ import type {UInt32Type, UInt64Type} from '@wharfkit/antelope'
2
+ import {ServerContract} from '../contracts'
3
+ import type {ComputedCapabilities} from '../derivation/capabilities'
4
+ import type {RouteMoverInput} from '../travel/route-simulator'
5
+ import type {GatherLane} from '../planner'
6
+
7
+ const U8_MAX = 255
8
+ const U16_MAX = 65_535
9
+ const U32_MAX = 4_294_967_295
10
+
11
+ function clampField(value: number, max: number): number {
12
+ return Math.min(max, Math.max(0, Math.floor(Number(value) || 0)))
13
+ }
14
+
15
+ export interface GathererLaneSource {
16
+ gathererLanes?: ComputedCapabilities['gathererLanes']
17
+ gatherer?: ComputedCapabilities['gatherer']
18
+ }
19
+
20
+ export function hydrateEntityLanes(caps: GathererLaneSource): GatherLane[] {
21
+ const lanes = caps.gathererLanes?.length
22
+ ? caps.gathererLanes
23
+ : caps.gatherer
24
+ ? [
25
+ {
26
+ slotIndex: 0,
27
+ yield: caps.gatherer.yield,
28
+ drain: caps.gatherer.drain,
29
+ depth: caps.gatherer.depth,
30
+ outputPct: 100,
31
+ },
32
+ ]
33
+ : []
34
+ return lanes.map((lane) =>
35
+ ServerContract.Types.gatherer_lane.from({
36
+ slot_index: clampField(lane.slotIndex, U8_MAX),
37
+ yield: clampField(lane.yield, U16_MAX),
38
+ drain: clampField(lane.drain, U32_MAX),
39
+ depth: clampField(lane.depth, U16_MAX),
40
+ output_pct: clampField(lane.outputPct, U16_MAX),
41
+ })
42
+ )
43
+ }
44
+
45
+ export interface RouteMoverRow {
46
+ id: UInt64Type
47
+ energy?: UInt32Type | number
48
+ cargomass?: UInt32Type | number
49
+ }
50
+
51
+ export type RouteMoverCaps = Pick<
52
+ ComputedCapabilities,
53
+ 'hullmass' | 'engines' | 'generator' | 'hauler' | 'loaderLanes'
54
+ >
55
+
56
+ export interface RouteMoverOpts {
57
+ entityType?: string
58
+ hasMovement?: boolean
59
+ priorMobilityEnd?: number
60
+ narrowBarrierEnd?: number
61
+ allLanesEnd?: number
62
+ }
63
+
64
+ export function entityToRouteMover(
65
+ row: RouteMoverRow,
66
+ caps: RouteMoverCaps,
67
+ opts: RouteMoverOpts = {}
68
+ ): RouteMoverInput {
69
+ const loaderMass = (caps.loaderLanes ?? []).reduce((sum, lane) => sum + Number(lane.mass), 0)
70
+ const mass = Number(row.cargomass ?? 0) + Number(caps.hullmass ?? 0) + loaderMass
71
+ return {
72
+ ref: {entityType: opts.entityType ?? 'ship', entityId: row.id},
73
+ hasMovement:
74
+ opts.hasMovement ?? (caps.engines !== undefined && caps.generator !== undefined),
75
+ engines: caps.engines,
76
+ generator: caps.generator,
77
+ hauler: caps.hauler
78
+ ? {capacity: caps.hauler.capacity, efficiency: caps.hauler.efficiency}
79
+ : undefined,
80
+ mass,
81
+ energy: Number(row.energy ?? 0),
82
+ priorMobilityEnd: opts.priorMobilityEnd ?? 0,
83
+ narrowBarrierEnd: opts.narrowBarrierEnd ?? 0,
84
+ allLanesEnd: opts.allLanesEnd,
85
+ }
86
+ }
package/src/errors.ts CHANGED
@@ -94,3 +94,12 @@ export const ITEM_NOT_AVAILABLE_AT_LOCATION = 'Item is not tradeable at ship loc
94
94
  export const INSUFFICIENT_BALANCE = 'Insufficient balance.'
95
95
  export const INSUFFICIENT_ITEM_QUANTITY = 'Insufficient quantity in cargo.'
96
96
  export const INSUFFICIENT_ITEM_SUPPLY = 'Insufficient supply of item at location.'
97
+
98
+ export const VOTE_NOT_CITIZEN = 'player is not a citizen of this world'
99
+ export const BALLOT_SETTLING = 'world is settling the last epoch'
100
+ export const BALLOT_NOT_DUE = 'ballot is not due'
101
+ export const BALLOT_NONE_DUE = 'no ballots are due'
102
+
103
+ export function isSettling(error: unknown): boolean {
104
+ return error instanceof Error && error.message.includes(BALLOT_SETTLING)
105
+ }
package/src/format.ts CHANGED
@@ -1,7 +1,9 @@
1
- export function formatMass(kg: number): string {
2
- if (kg === 0) return '0 t'
3
- const sign = kg < 0 ? '-' : ''
4
- const centitonnes = Math.round(Math.abs(kg) / 10)
1
+ import {MASS_UNITS_PER_TONNE} from './types'
2
+
3
+ export function formatMass(mass: number): string {
4
+ if (mass === 0) return '0 t'
5
+ const sign = mass < 0 ? '-' : ''
6
+ const centitonnes = Math.abs(mass) * 10
5
7
  const t = Math.floor(centitonnes / 100)
6
8
  const frac = centitonnes % 100
7
9
  if (frac === 0) return `${sign}${t} t`
@@ -9,10 +11,10 @@ export function formatMass(kg: number): string {
9
11
  return `${sign}${t}.${fracStr} t`
10
12
  }
11
13
 
12
- export function formatMassDelta(kg: number): string {
13
- if (kg === 0) return '0 t'
14
- const sign = kg > 0 ? '+' : '-'
15
- return `${sign}${formatMass(Math.abs(kg))}`
14
+ export function formatMassDelta(mass: number): string {
15
+ if (mass === 0) return '0 t'
16
+ const sign = mass > 0 ? '+' : '-'
17
+ return `${sign}${formatMass(Math.abs(mass))}`
16
18
  }
17
19
 
18
20
  export function formatLocation(loc: {x: number; y: number}): string {
@@ -23,14 +25,14 @@ function trim(n: number, digits = 1): string {
23
25
  return n.toFixed(digits).replace(/\.?0+$/, '')
24
26
  }
25
27
 
26
- export function formatMassScaled(kg: number): string {
27
- if (kg === 0) return '0 t'
28
- const sign = kg < 0 ? '-' : ''
29
- const tonnes = Math.abs(kg) / 1000
28
+ export function formatMassScaled(mass: number): string {
29
+ if (mass === 0) return '0 t'
30
+ const sign = mass < 0 ? '-' : ''
31
+ const tonnes = Math.abs(mass) / MASS_UNITS_PER_TONNE
30
32
  if (tonnes >= 1_000_000_000) return `${sign}${trim(tonnes / 1_000_000_000)}b t`
31
33
  if (tonnes >= 1_000_000) return `${sign}${trim(tonnes / 1_000_000)}m t`
32
34
  if (tonnes >= 1_000) return `${sign}${trim(tonnes / 1_000)}k t`
33
- return formatMass(kg)
35
+ return formatMass(mass)
34
36
  }
35
37
 
36
38
  export function formatInfluence(atomic: bigint | number | string): string {
@@ -8,6 +8,7 @@ export * from './data/recipes-runtime'
8
8
  // Preserves both value and type sides through rollup-plugin-dts bundling.
9
9
  export {Types as ServerTypes} from './contracts/server'
10
10
  export {Types as PlatformTypes} from './contracts/platform'
11
+ export {Types as FundTypes} from './contracts/fund'
11
12
 
12
13
  import type {ServerContract} from './contracts'
13
14
  import type {Entity as EntityType} from './entities/entity'
@@ -63,8 +64,10 @@ export type {
63
64
  FoundedWorld,
64
65
  FoundedWorldRef,
65
66
  InfluenceStanding,
66
- PendingBallot,
67
- VoteCast,
67
+ Ballot,
68
+ BallotVote,
69
+ NodeProgress,
70
+ QueuedSeat,
68
71
  VoteOption,
69
72
  VoteStandings,
70
73
  PlayerRosterEntry,
@@ -99,6 +102,7 @@ export {resolveLockedAmount, wrapCostKey} from './managers/nft'
99
102
  export {
100
103
  getItem,
101
104
  getItems,
105
+ tryGetItem,
102
106
  itemIds,
103
107
  getResources,
104
108
  getComponents,
@@ -258,6 +262,12 @@ export type {ReachStats} from './travel/reach'
258
262
  export {simulateRoute} from './travel/route-simulator'
259
263
  export type {RouteMoverInput, RouteLegSim, RouteSim} from './travel/route-simulator'
260
264
 
265
+ export {
266
+ planRouteWithRetry,
267
+ contractRouteLegCost,
268
+ contractRouteHeuristic,
269
+ } from './travel/route-build'
270
+
261
271
  export * as schedule from './scheduling/schedule'
262
272
  export {LANE_MOBILITY, LANE_BARRIER} from './scheduling/schedule'
263
273
  export type {
@@ -304,8 +314,8 @@ export type {
304
314
  ProjectionOptions,
305
315
  } from './scheduling/projection'
306
316
 
307
- export {taskCargoChanges} from './scheduling/task-cargo'
308
- export type {TaskCargoChange, TaskCargoDirection} from './scheduling/task-cargo'
317
+ export {taskCargoChanges, taskCargoChangesChecked} from './scheduling/task-cargo'
318
+ export type {TaskCargoChange, TaskCargoDirection, TaskCargoResult} from './scheduling/task-cargo'
309
319
 
310
320
  export {
311
321
  jobsToLanes,
@@ -346,12 +356,13 @@ export {
346
356
  cargoReadyAt,
347
357
  taskCargoEffect,
348
358
  calcCounterpartDelivery,
359
+ incomingSources,
349
360
  cargoKey,
350
361
  cargoInputKey,
351
362
  hasSourceCoupling,
352
363
  hasIncomingCoupling,
353
364
  } from './scheduling/availability'
354
- export type {CargoInput, IncomingSource} from './scheduling/availability'
365
+ export type {CargoInput, IncomingHoldEntity, IncomingSource} from './scheduling/availability'
355
366
 
356
367
  export {clusterStockAvailable} from './scheduling/cluster-stock'
357
368
 
@@ -385,6 +396,10 @@ export {
385
396
  CAP_UNDEPLOY,
386
397
  CAP_DEMOLISH,
387
398
  CAP_MODULES,
399
+ CAP_CATCH,
400
+ CAP_STORE_CARGO,
401
+ canCatch,
402
+ canStoreCargo,
388
403
  isShip,
389
404
  isWarehouse,
390
405
  isExtractor,
@@ -656,17 +671,29 @@ export {
656
671
  gatherEnergyCost,
657
672
  maxQtyForCharge,
658
673
  planParallelTransfer,
674
+ planSingleGather,
659
675
  splitCost,
660
676
  } from './planner'
661
677
  export type {
662
678
  BuildGatherPlanOpts,
663
679
  FillCap,
664
680
  GatherCycle,
681
+ GatherLane,
665
682
  GatherLimpet,
666
683
  GatherPlan,
667
684
  GatherPlanEntity,
668
685
  LanePlanEntry,
669
686
  PlanTarget,
687
+ SingleGatherInput,
688
+ SingleGatherPlan,
670
689
  } from './planner'
671
690
 
691
+ export {entityToRouteMover, hydrateEntityLanes} from './entity/hydrate'
692
+ export type {
693
+ GathererLaneSource,
694
+ RouteMoverCaps,
695
+ RouteMoverOpts,
696
+ RouteMoverRow,
697
+ } from './entity/hydrate'
698
+
672
699
  export * from './influence'
@@ -0,0 +1,103 @@
1
+ import {describe, expect, test} from 'bun:test'
2
+ import {
3
+ charterEligible,
4
+ charterEligibleChained,
5
+ CHARTER_REGISTRY,
6
+ type CharterWorld,
7
+ } from './charters'
8
+ import {projectBallot} from './ballot'
9
+
10
+ const NEXUS = 20001
11
+ const DOCK = 30100001
12
+ const WORKSHOP_TUNEUP = 10100201
13
+ const DOCK_TUNEUP = 30100301
14
+ const DEPOT = 40100001
15
+
16
+ const world: CharterWorld = {built: [{nodeId: 10001, repeats: 0}]}
17
+
18
+ describe('charterEligibleChained', () => {
19
+ test('with nothing seated it equals charterEligible', () => {
20
+ for (const node of CHARTER_REGISTRY) {
21
+ expect(charterEligibleChained(world, node, [])).toBe(charterEligible(world, node))
22
+ }
23
+ })
24
+
25
+ test('a seated prerequisite opens a dependent, a seated node closes itself', () => {
26
+ const tuneup = CHARTER_REGISTRY.find((n) => n.nodeId === DOCK_TUNEUP)!
27
+ expect(charterEligibleChained(world, tuneup, [])).toBe(false)
28
+ expect(charterEligibleChained(world, tuneup, [DOCK])).toBe(true)
29
+ const dock = CHARTER_REGISTRY.find((n) => n.nodeId === DOCK)!
30
+ expect(charterEligibleChained(world, dock, [DOCK])).toBe(false)
31
+ })
32
+ })
33
+
34
+ describe('projectBallot', () => {
35
+ const voters = [
36
+ {account: 'carol', weight: 9_000n, picks: [DOCK, DOCK_TUNEUP]},
37
+ {account: 'dave', weight: 5_000n, picks: [NEXUS, DOCK_TUNEUP]},
38
+ {account: 'erin', weight: 0n, picks: [NEXUS]},
39
+ ]
40
+
41
+ test('one seat: the heavier pick wins and the frontier carries round weights', () => {
42
+ const result = projectBallot({world, seats: 1, voters})
43
+ expect(result.seats).toEqual([{nodeId: DOCK, weight: 9_000n}])
44
+ const nexus = result.options.find((o) => o.nodeId === NEXUS)!
45
+ expect(nexus.seat).toBe(0)
46
+ expect(nexus.weight).toBe(5_000n)
47
+ const tuneup = result.options.find((o) => o.nodeId === DOCK_TUNEUP)!
48
+ expect(tuneup.seat).toBe(0)
49
+ expect(tuneup.weight).toBe(9_000n)
50
+ expect(result.options.find((o) => o.nodeId === 10001)).toBeUndefined()
51
+ })
52
+
53
+ test('two seats: the dock tune-up chains behind the dock', () => {
54
+ const result = projectBallot({world, seats: 2, voters})
55
+ expect(result.seats.map((s) => s.nodeId)).toEqual([DOCK, DOCK_TUNEUP])
56
+ expect(result.seats[1].weight).toBe(9_000n)
57
+ })
58
+
59
+ test('a tie goes to the lower option id', () => {
60
+ const tied = [
61
+ {account: 'a', weight: 100n, picks: [DOCK]},
62
+ {account: 'b', weight: 100n, picks: [NEXUS]},
63
+ ]
64
+ expect(projectBallot({world, seats: 1, voters: tied}).seats[0].nodeId).toBe(NEXUS)
65
+ })
66
+
67
+ test('a zero round ends early and an empty queue stays empty, even with one node left', () => {
68
+ expect(projectBallot({world, seats: 4, voters}).seats.length).toBe(3)
69
+ expect(projectBallot({world, seats: 1, voters: []}).seats).toEqual([])
70
+ const almostDone: CharterWorld = {
71
+ built: CHARTER_REGISTRY.filter((n) => n.nodeId !== 40100404).map((n) => ({
72
+ nodeId: n.nodeId,
73
+ repeats: 0,
74
+ })),
75
+ }
76
+ expect(projectBallot({world: almostDone, seats: 1, voters: []}).seats).toEqual([])
77
+ expect(
78
+ projectBallot({
79
+ world: almostDone,
80
+ seats: 1,
81
+ voters: [{account: 'a', weight: 5n, picks: [40100404]}],
82
+ }).seats
83
+ ).toEqual([{nodeId: 40100404, weight: 5n}])
84
+ })
85
+
86
+ test('the caller picks are ranked on the options', () => {
87
+ const result = projectBallot({world, seats: 1, voters, picks: [NEXUS, DOCK]})
88
+ expect(result.options.find((o) => o.nodeId === NEXUS)!.rank).toBe(1)
89
+ expect(result.options.find((o) => o.nodeId === DOCK)!.rank).toBe(2)
90
+ expect(result.options.find((o) => o.nodeId === WORKSHOP_TUNEUP)!.rank).toBe(0)
91
+ })
92
+
93
+ test('a pick slot is consumed by the first eligible option regardless of the voter weight behind it', () => {
94
+ const scenario = [
95
+ {account: 'grace', weight: 50n, picks: [NEXUS, DEPOT]},
96
+ {account: 'frank', weight: 45n, picks: [DEPOT]},
97
+ {account: 'erin', weight: 0n, picks: [NEXUS, DEPOT]},
98
+ ]
99
+ expect(projectBallot({world, seats: 1, voters: scenario}).seats).toEqual([
100
+ {nodeId: NEXUS, weight: 50n},
101
+ ])
102
+ })
103
+ })
@@ -0,0 +1,98 @@
1
+ import {CHARTER_REGISTRY, charterEligibleChained, type CharterWorld} from './charters'
2
+ import {CHARTER_NONE} from './constants'
3
+
4
+ export interface BallotVoter {
5
+ account: string
6
+ weight: bigint
7
+ picks: number[]
8
+ }
9
+
10
+ /** Projects a full settlement from scratch; it does not resume an in-flight partial round. */
11
+ export interface BallotInput {
12
+ world: CharterWorld
13
+ seats: number
14
+ voters: BallotVoter[]
15
+ picks?: number[]
16
+ }
17
+
18
+ export interface ProjectedSeat {
19
+ nodeId: number
20
+ weight: bigint
21
+ }
22
+
23
+ export interface ProjectedOption {
24
+ nodeId: number
25
+ cost: bigint
26
+ seat: number
27
+ weight: bigint
28
+ rank: number
29
+ }
30
+
31
+ export interface BallotProjection {
32
+ seats: ProjectedSeat[]
33
+ options: ProjectedOption[]
34
+ }
35
+
36
+ function tallyRound(input: BallotInput, seated: number[]): Map<number, bigint> {
37
+ const tallies = new Map<number, bigint>()
38
+ for (const voter of input.voters) {
39
+ for (const pick of voter.picks) {
40
+ const node = CHARTER_REGISTRY.find((n) => n.nodeId === pick)
41
+ if (!node || !charterEligibleChained(input.world, node, seated)) continue
42
+ if (voter.weight > 0n) tallies.set(pick, (tallies.get(pick) ?? 0n) + voter.weight)
43
+ break
44
+ }
45
+ }
46
+ return tallies
47
+ }
48
+
49
+ function leader(tallies: Map<number, bigint>): ProjectedSeat | undefined {
50
+ let winner = CHARTER_NONE
51
+ let best = 0n
52
+ for (const [option, weight] of tallies) {
53
+ if (weight === 0n) continue
54
+ if (winner === CHARTER_NONE || weight > best || (weight === best && option < winner)) {
55
+ best = weight
56
+ winner = option
57
+ }
58
+ }
59
+ return winner === CHARTER_NONE ? undefined : {nodeId: winner, weight: best}
60
+ }
61
+
62
+ export function projectBallot(input: BallotInput): BallotProjection {
63
+ const seats: ProjectedSeat[] = []
64
+ while (seats.length < input.seats) {
65
+ const seat = leader(
66
+ tallyRound(
67
+ input,
68
+ seats.map((s) => s.nodeId)
69
+ )
70
+ )
71
+ if (!seat) break
72
+ seats.push(seat)
73
+ }
74
+
75
+ const projected = seats.map((s) => s.nodeId)
76
+ const next = tallyRound(input, projected)
77
+ const picks = input.picks ?? []
78
+
79
+ const options: ProjectedOption[] = []
80
+ for (const node of CHARTER_REGISTRY) {
81
+ const seatIndex = projected.indexOf(node.nodeId)
82
+ const option: ProjectedOption = {
83
+ nodeId: node.nodeId,
84
+ cost: node.cost,
85
+ seat: seatIndex + 1,
86
+ weight: 0n,
87
+ rank: picks.indexOf(node.nodeId) + 1,
88
+ }
89
+ if (seatIndex >= 0) {
90
+ option.weight = seats[seatIndex].weight
91
+ } else {
92
+ if (!charterEligibleChained(input.world, node, projected)) continue
93
+ option.weight = next.get(node.nodeId) ?? 0n
94
+ }
95
+ options.push(option)
96
+ }
97
+ return {seats, options}
98
+ }