@shipload/sdk 1.0.0-next.3 → 1.0.0-next.30

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 (99) hide show
  1. package/lib/shipload.d.ts +1847 -962
  2. package/lib/shipload.js +9088 -4854
  3. package/lib/shipload.js.map +1 -1
  4. package/lib/shipload.m.js +8957 -4805
  5. package/lib/shipload.m.js.map +1 -1
  6. package/lib/testing.d.ts +856 -0
  7. package/lib/testing.js +3739 -0
  8. package/lib/testing.js.map +1 -0
  9. package/lib/testing.m.js +3733 -0
  10. package/lib/testing.m.js.map +1 -0
  11. package/package.json +15 -2
  12. package/src/capabilities/craftable.ts +51 -0
  13. package/src/capabilities/crafting.test.ts +7 -0
  14. package/src/capabilities/crafting.ts +3 -3
  15. package/src/capabilities/gathering.ts +17 -7
  16. package/src/capabilities/index.ts +0 -1
  17. package/src/capabilities/modules.ts +6 -0
  18. package/src/capabilities/storage.ts +16 -1
  19. package/src/contracts/platform.ts +231 -3
  20. package/src/contracts/server.ts +816 -471
  21. package/src/data/capabilities.ts +14 -329
  22. package/src/data/capability-formulas.ts +76 -0
  23. package/src/data/catalog.ts +0 -5
  24. package/src/data/colors.ts +14 -47
  25. package/src/data/entities.json +46 -10
  26. package/src/data/item-ids.ts +15 -12
  27. package/src/data/items.json +302 -38
  28. package/src/data/kind-registry.json +85 -0
  29. package/src/data/kind-registry.ts +150 -0
  30. package/src/data/metadata.ts +100 -31
  31. package/src/data/recipes-runtime.ts +3 -23
  32. package/src/data/recipes.json +250 -113
  33. package/src/derivation/build-methods.ts +45 -0
  34. package/src/derivation/capabilities.ts +415 -0
  35. package/src/derivation/capability-mappings.ts +117 -0
  36. package/src/derivation/crafting.ts +23 -24
  37. package/src/derivation/index.ts +17 -2
  38. package/src/derivation/reserve-regen.ts +34 -0
  39. package/src/derivation/resources.ts +125 -38
  40. package/src/derivation/stars.test.ts +51 -0
  41. package/src/derivation/stars.ts +15 -0
  42. package/src/derivation/stats.ts +6 -6
  43. package/src/derivation/stratum.ts +15 -19
  44. package/src/derivation/tiers.ts +28 -7
  45. package/src/entities/entity.ts +98 -0
  46. package/src/entities/gamestate.ts +3 -28
  47. package/src/entities/makers.ts +91 -136
  48. package/src/entities/slot-multiplier.ts +39 -0
  49. package/src/errors.ts +10 -15
  50. package/src/format.ts +26 -4
  51. package/src/index-module.ts +189 -47
  52. package/src/managers/actions.ts +252 -83
  53. package/src/managers/base.ts +6 -2
  54. package/src/managers/construction-types.ts +79 -0
  55. package/src/managers/construction.ts +396 -0
  56. package/src/managers/context.ts +11 -1
  57. package/src/managers/entities.ts +18 -66
  58. package/src/managers/epochs.ts +40 -0
  59. package/src/managers/index.ts +17 -1
  60. package/src/managers/locations.ts +25 -29
  61. package/src/managers/nft.ts +28 -0
  62. package/src/managers/plot.ts +127 -0
  63. package/src/nft/atomicassets.abi.json +1342 -0
  64. package/src/nft/atomicassets.ts +237 -0
  65. package/src/nft/atomicdata.ts +130 -0
  66. package/src/nft/buildImmutableData.ts +321 -0
  67. package/src/nft/description.ts +37 -15
  68. package/src/nft/index.ts +3 -0
  69. package/src/resolution/describe-module.ts +5 -8
  70. package/src/resolution/display-name.ts +38 -10
  71. package/src/resolution/resolve-item.ts +22 -20
  72. package/src/scheduling/accessor.ts +68 -22
  73. package/src/scheduling/availability.ts +108 -0
  74. package/src/scheduling/energy.ts +48 -0
  75. package/src/scheduling/lane-core.ts +130 -0
  76. package/src/scheduling/lanes.ts +60 -0
  77. package/src/scheduling/projection.ts +121 -94
  78. package/src/scheduling/schedule.ts +237 -103
  79. package/src/scheduling/task-cargo.ts +46 -0
  80. package/src/shipload.ts +16 -1
  81. package/src/subscriptions/manager.ts +40 -6
  82. package/src/subscriptions/mappers.ts +3 -8
  83. package/src/subscriptions/types.ts +3 -2
  84. package/src/testing/catalog-hash.ts +19 -0
  85. package/src/testing/index.ts +2 -0
  86. package/src/testing/projection-parity.ts +143 -0
  87. package/src/travel/travel.ts +90 -13
  88. package/src/types/capabilities.ts +1 -0
  89. package/src/types/index.ts +0 -1
  90. package/src/types.ts +19 -12
  91. package/src/utils/cargo.ts +27 -0
  92. package/src/utils/display-name.ts +61 -0
  93. package/src/utils/system.ts +25 -24
  94. package/src/capabilities/loading.ts +0 -8
  95. package/src/entities/container.ts +0 -108
  96. package/src/entities/ship-deploy.ts +0 -258
  97. package/src/entities/ship.ts +0 -204
  98. package/src/entities/warehouse.ts +0 -119
  99. package/src/types/entity-traits.ts +0 -69
@@ -1,42 +1,57 @@
1
1
  import {Name, UInt16, UInt32, UInt64, UInt8} from '@wharfkit/antelope'
2
+ import type {NameType, UInt64Type} from '@wharfkit/antelope'
2
3
  import {ServerContract} from '../contracts'
3
- import {type PackedModuleInput, Ship, type ShipStateInput} from './ship'
4
- import {computeWarehouseCapabilities, Warehouse, type WarehouseStateInput} from './warehouse'
5
- import {Container, type ContainerStateInput} from './container'
6
- import {ITEM_SHIP_T1_PACKED, ITEM_WAREHOUSE_T1_PACKED} from '../data/item-ids'
4
+ import {Entity} from './entity'
5
+ import {getKindMeta, getTemplateMeta} from '../data/kind-registry'
6
+ import type {EntityTypeName} from '../data/kind-registry'
7
7
  import {getEntityLayout} from '../data/recipes-runtime'
8
+ import type {EntitySlot} from '../data/recipes-runtime'
8
9
  import {itemMetadata} from '../data/metadata'
9
10
  import {getItem} from '../data/catalog'
10
- import {
11
- getModuleCapabilityType,
12
- MODULE_STORAGE,
13
- moduleAccepts,
14
- moduleSlotTypeToCode,
15
- } from '../capabilities/modules'
16
- import {computeShipCapabilities, computeStorageCapabilities} from './ship-deploy'
17
- import {decodeCraftedItemStats} from '../derivation/crafting'
11
+ import {getModuleCapabilityType, moduleAccepts, moduleSlotTypeToCode} from '../capabilities/modules'
12
+ import {computeEntityCapabilities} from '../derivation/capabilities'
13
+ import {packedModulesToInstalled} from './slot-multiplier'
14
+ import {LANE_MOBILITY} from '../scheduling/schedule'
15
+
16
+ export interface PackedModuleInput {
17
+ itemId: number
18
+ stats: bigint
19
+ }
20
+
21
+ export interface EntityStateInput {
22
+ id: UInt64Type
23
+ owner: NameType
24
+ name: string
25
+ coordinates: {x: number; y: number; z?: number}
26
+ itemId?: number
27
+ hullmass?: number
28
+ capacity?: number
29
+ cargomass?: number
30
+ energy?: number
31
+ modules?: PackedModuleInput[]
32
+ schedule?: ServerContract.Types.schedule
33
+ lanes?: ServerContract.Types.lane[]
34
+ cargo?: ServerContract.Types.cargo_item[]
35
+ }
18
36
 
19
37
  function assignModulesToSlots(
20
- packedEntityItemId: number,
38
+ slots: EntitySlot[],
21
39
  modules: PackedModuleInput[],
22
40
  entityLabel: string
23
41
  ): ServerContract.Types.module_entry[] {
24
- const layout = getEntityLayout(packedEntityItemId)
25
- const slots = layout?.slots ?? []
26
42
  const result: Array<{type: number; installed?: ServerContract.Types.packed_module}> = slots.map(
27
43
  (s) => ({type: moduleSlotTypeToCode(s.type), installed: undefined})
28
44
  )
29
45
 
30
46
  for (const mod of modules) {
31
- const itemId = Number(UInt16.from(mod.itemId).value.toString())
32
- const modType = getModuleCapabilityType(itemId)
47
+ const modType = getModuleCapabilityType(mod.itemId)
33
48
  const slotIdx = result.findIndex((r) => !r.installed && moduleAccepts(r.type, modType))
34
49
  if (slotIdx === -1) {
35
50
  let modName: string
36
51
  try {
37
- modName = getItem(itemId).name
52
+ modName = getItem(mod.itemId).name
38
53
  } catch {
39
- modName = itemMetadata[itemId]?.name ?? `item ${itemId}`
54
+ modName = itemMetadata[mod.itemId]?.name ?? `item ${mod.itemId}`
40
55
  }
41
56
  throw new Error(
42
57
  `No compatible slot for module ${modName} (type ${modType}) on ${entityLabel}`
@@ -56,141 +71,81 @@ function assignModulesToSlots(
56
71
  )
57
72
  }
58
73
 
59
- function decodePackedInput(m: PackedModuleInput): {itemId: number; stats: bigint} {
60
- return {
61
- itemId: Number(UInt16.from(m.itemId).value.toString()),
62
- stats: BigInt(UInt64.from(m.stats).toString()),
63
- }
74
+ const ZERO_HULL_STATS: Record<string, number> = {
75
+ density: 0,
76
+ strength: 0,
77
+ hardness: 0,
78
+ cohesion: 0,
64
79
  }
65
80
 
66
- function computeStorageBonus(
67
- decoded: {itemId: number; stats: bigint}[],
68
- baseCapacity: number
69
- ): number {
70
- let totalBonus = 0
71
- for (const m of decoded) {
72
- if (getModuleCapabilityType(m.itemId) !== MODULE_STORAGE) continue
73
- const stats = decodeCraftedItemStats(m.itemId, m.stats)
74
- const {capacityBonus} = computeStorageCapabilities(stats, baseCapacity)
75
- totalBonus += capacityBonus
81
+ export function makeEntity(packedItemId: number, state: EntityStateInput): Entity {
82
+ const template = getTemplateMeta(packedItemId)
83
+ if (!template) {
84
+ throw new Error(`Unknown packed entity item ID: ${packedItemId}`)
76
85
  }
77
- return totalBonus
78
- }
79
86
 
80
- function deriveShipFromModules(
81
- modules: PackedModuleInput[],
82
- baseCapacity: number
83
- ): {
84
- capabilities: ReturnType<typeof computeShipCapabilities>
85
- finalCapacity: number
86
- } {
87
- const decoded = modules.map(decodePackedInput)
88
- const capabilities = computeShipCapabilities(decoded)
89
- const totalBonus = computeStorageBonus(decoded, baseCapacity)
90
- return {capabilities, finalCapacity: baseCapacity + totalBonus}
91
- }
87
+ const kind = template.kind.toString() as EntityTypeName
88
+ const layout = getEntityLayout(packedItemId)?.slots ?? []
89
+ const mods = state.modules ?? []
90
+
91
+ const lanes =
92
+ state.lanes ??
93
+ (state.schedule
94
+ ? [
95
+ ServerContract.Types.lane.from({
96
+ lane_key: UInt8.from(LANE_MOBILITY),
97
+ schedule: state.schedule,
98
+ }),
99
+ ]
100
+ : [])
92
101
 
93
- export function makeShip(state: ShipStateInput): Ship {
94
102
  const info: Record<string, unknown> = {
95
- type: Name.from('ship'),
103
+ type: template.kind,
96
104
  id: UInt64.from(state.id),
97
105
  owner: Name.from(state.owner),
98
106
  entity_name: state.name,
99
107
  coordinates: ServerContract.Types.coordinates.from(state.coordinates),
100
- cargomass: UInt32.from(0),
108
+ item_id: UInt16.from(state.itemId ?? template.itemId),
109
+ cargomass: UInt32.from(state.cargomass ?? 0),
101
110
  cargo: state.cargo || [],
102
- is_idle: !state.schedule,
103
- current_task_elapsed: UInt32.from(0),
104
- current_task_remaining: UInt32.from(0),
105
- pending_tasks: [],
111
+ lanes,
106
112
  }
107
- if (state.hullmass !== undefined) info.hullmass = UInt32.from(state.hullmass)
113
+
108
114
  if (state.energy !== undefined) info.energy = UInt16.from(state.energy)
109
- if (state.schedule) info.schedule = state.schedule
110
-
111
- let moduleEntries: ServerContract.Types.module_entry[] = []
112
- if (state.modules && state.modules.length > 0) {
113
- moduleEntries = assignModulesToSlots(ITEM_SHIP_T1_PACKED, state.modules, 'Ship T1')
114
- const {capabilities, finalCapacity} = deriveShipFromModules(
115
- state.modules,
116
- state.capacity ?? 0
117
- )
118
- if (capabilities.engines) info.engines = capabilities.engines
119
- if (capabilities.generator) info.generator = capabilities.generator
120
- if (capabilities.gatherer) info.gatherer = capabilities.gatherer
121
- if (capabilities.hauler) info.hauler = capabilities.hauler
122
- if (capabilities.loaders) info.loaders = capabilities.loaders
123
- if (capabilities.crafter) info.crafter = capabilities.crafter
124
- if (state.capacity !== undefined) info.capacity = UInt32.from(finalCapacity)
125
- } else {
126
- moduleEntries = assignModulesToSlots(ITEM_SHIP_T1_PACKED, [], 'Ship T1')
115
+
116
+ if (kind === 'container') {
117
+ info.modules = []
118
+ if (state.hullmass !== undefined) info.hullmass = UInt32.from(state.hullmass)
127
119
  if (state.capacity !== undefined) info.capacity = UInt32.from(state.capacity)
128
- }
120
+ } else {
121
+ const entityLabel = getKindMeta(template.kind)?.defaultLabel ?? kind
122
+ const moduleEntries = assignModulesToSlots(layout, mods, entityLabel)
123
+ info.modules = moduleEntries
129
124
 
130
- info.modules = moduleEntries
125
+ const installed = packedModulesToInstalled(moduleEntries)
126
+ const caps = computeEntityCapabilities(ZERO_HULL_STATS, packedItemId, installed, layout)
131
127
 
132
- const entityInfo = ServerContract.Types.entity_info.from(info)
133
- return new Ship(entityInfo)
134
- }
128
+ if (state.hullmass !== undefined) {
129
+ info.hullmass = UInt32.from(state.hullmass)
130
+ } else if (installed.length > 0) {
131
+ info.hullmass = UInt32.from(caps.hullmass)
132
+ }
135
133
 
136
- export function makeWarehouse(state: WarehouseStateInput): Warehouse {
137
- const info: Record<string, unknown> = {
138
- type: Name.from('warehouse'),
139
- id: UInt64.from(state.id),
140
- owner: Name.from(state.owner),
141
- entity_name: state.name,
142
- coordinates: ServerContract.Types.coordinates.from(state.coordinates),
143
- capacity: UInt32.from(state.capacity),
144
- cargomass: UInt32.from(0),
145
- cargo: state.cargo || [],
146
- is_idle: !state.schedule,
147
- current_task_elapsed: UInt32.from(0),
148
- current_task_remaining: UInt32.from(0),
149
- pending_tasks: [],
150
- }
151
- if (state.hullmass !== undefined) info.hullmass = UInt32.from(state.hullmass)
152
- if (state.schedule) info.schedule = state.schedule
153
-
154
- let moduleEntries: ServerContract.Types.module_entry[] = []
155
- if (state.modules && state.modules.length > 0) {
156
- moduleEntries = assignModulesToSlots(
157
- ITEM_WAREHOUSE_T1_PACKED,
158
- state.modules,
159
- 'Warehouse T1'
160
- )
161
- const decoded = state.modules.map(decodePackedInput)
162
- const capabilities = computeWarehouseCapabilities(decoded)
163
- if (capabilities.loaders) info.loaders = capabilities.loaders
164
-
165
- const totalBonus = computeStorageBonus(decoded, state.capacity)
166
- info.capacity = UInt32.from(state.capacity + totalBonus)
167
- } else {
168
- moduleEntries = assignModulesToSlots(ITEM_WAREHOUSE_T1_PACKED, [], 'Warehouse T1')
169
- }
134
+ if (state.capacity !== undefined) {
135
+ info.capacity = UInt32.from(state.capacity)
136
+ } else {
137
+ info.capacity = UInt32.from(caps.capacity)
138
+ }
170
139
 
171
- info.modules = moduleEntries
140
+ if (caps.engines) info.engines = caps.engines
141
+ if (caps.generator) info.generator = caps.generator
142
+ if (caps.gatherer) info.gatherer = caps.gatherer
143
+ if (caps.loaders) info.loaders = caps.loaders
144
+ if (caps.crafter) info.crafter = caps.crafter
145
+ if (caps.hauler) info.hauler = caps.hauler
146
+ if (caps.warp) info.warp = caps.warp
147
+ }
172
148
 
173
149
  const entityInfo = ServerContract.Types.entity_info.from(info)
174
- return new Warehouse(entityInfo)
175
- }
176
-
177
- export function makeContainer(state: ContainerStateInput): Container {
178
- const entityInfo = ServerContract.Types.entity_info.from({
179
- type: Name.from('container'),
180
- id: UInt64.from(state.id),
181
- owner: Name.from(state.owner),
182
- entity_name: state.name,
183
- coordinates: ServerContract.Types.coordinates.from(state.coordinates),
184
- hullmass: UInt32.from(state.hullmass),
185
- capacity: UInt32.from(state.capacity),
186
- cargomass: UInt32.from(state.cargomass || 0),
187
- cargo: state.cargo || [],
188
- modules: [],
189
- is_idle: !state.schedule,
190
- current_task_elapsed: UInt32.from(0),
191
- current_task_remaining: UInt32.from(0),
192
- pending_tasks: [],
193
- schedule: state.schedule,
194
- })
195
- return new Container(entityInfo)
150
+ return new Entity(entityInfo)
196
151
  }
@@ -0,0 +1,39 @@
1
+ import type {EntitySlot} from '../data/recipes-runtime'
2
+ import type {ServerContract} from '../contracts'
3
+
4
+ export const U16_MAX = 65535
5
+
6
+ export interface InstalledModule {
7
+ slotIndex: number
8
+ itemId: number
9
+ stats: bigint
10
+ }
11
+
12
+ export function packedModulesToInstalled(
13
+ entries: ServerContract.Types.module_entry[]
14
+ ): InstalledModule[] {
15
+ const installed: InstalledModule[] = []
16
+ entries.forEach((entry, slotIndex) => {
17
+ if (!entry.installed) return
18
+ installed.push({
19
+ slotIndex,
20
+ itemId: Number(entry.installed.item_id.value),
21
+ stats: BigInt(entry.installed.stats.toString()),
22
+ })
23
+ })
24
+ return installed
25
+ }
26
+
27
+ export function clampUint16(value: number): number {
28
+ return Math.min(value, U16_MAX)
29
+ }
30
+
31
+ export const clampUint32 = (v: number): number => Math.min(Math.max(Math.floor(v), 0), 4294967295)
32
+
33
+ export function applySlotMultiplier(value: number, outputPct: number): number {
34
+ return clampUint16(Math.floor((value * outputPct) / 100))
35
+ }
36
+
37
+ export function getSlotAmp(layout: EntitySlot[], slotIndex: number): number {
38
+ return layout[slotIndex]?.outputPct ?? 100
39
+ }
package/src/errors.ts CHANGED
@@ -16,20 +16,18 @@ export const REQUIRES_POSITIVE_VALUE = 'Value must be greater than zero.'
16
16
  export const PLAYER_ALREADY_JOINED = 'Player has already joined the game.'
17
17
  export const PLAYER_NOT_JOINED = 'Player has not joined the game.'
18
18
  export const PLAYER_NOT_FOUND = 'Cannot find player for given account name.'
19
- export const STARTER_ALREADY_CLAIMED =
20
- 'Starter ship already claimed; destroy existing ships to re-claim.'
21
- export const SHIP_ALREADY_THERE = 'Ship cannot travel to the location its already at.'
19
+ export const ENTITY_ALREADY_THERE = 'Entity cannot travel to the location it is already at.'
22
20
  export const SHIP_ALREADY_TRAVELING = 'Ship is already traveling.'
23
21
  export const SHIP_CANNOT_BUY_TRAVELING = 'Ship cannot buy goods while traveling.'
24
22
  export const SHIP_CANNOT_UPDATE_TRAVELING = 'Ship cannot be updated while traveling.'
25
- export const SHIP_INVALID_DESTINATION = 'Ship cannot travel, no system at specified destination.'
26
- export const SHIP_INVALID_TRAVEL_DURATION =
23
+ export const ENTITY_INVALID_DESTINATION = 'Cannot travel: no system at specified destination.'
24
+ export const ENTITY_INVALID_TRAVEL_DURATION =
27
25
  'This trip cannot be made as it would exceed the maximum travel duration.'
28
26
  export const SHIP_NOT_ARRIVED = 'Ship has not yet arrived at its destination.'
29
- export const SHIP_NOT_ENOUGH_ENERGY =
30
- 'Ship does not have enough energy to travel to the destination.'
31
- export const SHIP_NOT_ENOUGH_ENERGY_CAPACITY =
32
- 'Ship does not have enough energy capacity to travel.'
27
+ export const ENTITY_NOT_ENOUGH_ENERGY =
28
+ 'Entity does not have enough energy to travel to the destination.'
29
+ export const ENTITY_NOT_ENOUGH_ENERGY_CAPACITY =
30
+ 'Entity does not have enough energy capacity to travel.'
33
31
  export const SHIP_NOT_FOUND = 'Cannot find ship for given account.'
34
32
  export const SHIP_NOT_OWNED = 'Ship is not owned by this account.'
35
33
  export const NO_SCHEDULE = 'No scheduled tasks.'
@@ -38,16 +36,13 @@ export const SHIP_NO_COMPLETED_TASKS = 'No completed tasks to resolve.'
38
36
  export const RESOLVE_COUNT_EXCEEDS_COMPLETED = 'Requested resolve count exceeds completed tasks.'
39
37
  export const SHIP_CANNOT_CANCEL_TASK = 'Cannot cancel task that is immutable or in progress.'
40
38
  export const SHIP_NO_TASKS_TO_CANCEL = 'No tasks to cancel.'
41
- export const SHIP_INVALID_CARGO = 'Invalid cargo specified for load/unload.'
42
- export const SHIP_CARGO_NOT_OWNED = 'Cannot load cargo that is not owned.'
43
- export const SHIP_CARGO_NOT_LOADED = 'Cannot unload cargo that is not loaded.'
44
- export const SHIP_CAPACITY_EXCEEDED = 'Ship cargo capacity would be exceeded.'
39
+ export const ENTITY_INVALID_CARGO = 'Invalid cargo specified for load/unload.'
40
+ export const ENTITY_CARGO_NOT_OWNED = 'Cannot load cargo that is not owned.'
41
+ export const ENTITY_CARGO_NOT_LOADED = 'Cannot unload cargo that is not loaded.'
45
42
  export const ENTITY_CAPACITY_EXCEEDED = 'Entity cargo capacity would be exceeded.'
46
43
  export const WAREHOUSE_NOT_FOUND = 'Cannot find warehouse for given id.'
47
44
  export const WAREHOUSE_ALREADY_AT_LOCATION = 'Warehouse already exists at this location.'
48
- export const WAREHOUSE_CAPACITY_EXCEEDED = 'Warehouse capacity would be exceeded.'
49
45
  export const CONTAINER_NOT_FOUND = 'Cannot find container for given id.'
50
- export const CONTAINER_CAPACITY_EXCEEDED = 'Container capacity would be exceeded.'
51
46
  export const DESTINATION_CAPACITY_EXCEEDED =
52
47
  'Destination entity does not have enough capacity for the gather.'
53
48
  export const CANCEL_PAIRED_HAS_PENDING = 'Cannot cancel transfer, paired entity has pending tasks.'
package/src/format.ts CHANGED
@@ -1,8 +1,12 @@
1
1
  export function formatMass(kg: number): string {
2
- const t = kg / 1000
3
- const fixed = t.toFixed(2)
4
- const trimmed = fixed.replace(/\.?0+$/, '')
5
- return `${trimmed} t`
2
+ if (kg === 0) return '0 t'
3
+ const sign = kg < 0 ? '-' : ''
4
+ const centitonnes = Math.round(Math.abs(kg) / 10)
5
+ const t = Math.floor(centitonnes / 100)
6
+ const frac = centitonnes % 100
7
+ if (frac === 0) return `${sign}${t} t`
8
+ const fracStr = String(frac).padStart(2, '0').replace(/0$/, '')
9
+ return `${sign}${t}.${fracStr} t`
6
10
  }
7
11
 
8
12
  export function formatMassDelta(kg: number): string {
@@ -10,3 +14,21 @@ export function formatMassDelta(kg: number): string {
10
14
  const sign = kg > 0 ? '+' : '-'
11
15
  return `${sign}${formatMass(Math.abs(kg))}`
12
16
  }
17
+
18
+ export function formatLocation(loc: {x: number; y: number}): string {
19
+ return `${loc.x}, ${loc.y}`
20
+ }
21
+
22
+ function trim(n: number, digits = 1): string {
23
+ return n.toFixed(digits).replace(/\.?0+$/, '')
24
+ }
25
+
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
30
+ if (tonnes >= 1_000_000_000) return `${sign}${trim(tonnes / 1_000_000_000)}b t`
31
+ if (tonnes >= 1_000_000) return `${sign}${trim(tonnes / 1_000_000)}m t`
32
+ if (tonnes >= 1_000) return `${sign}${trim(tonnes / 1_000)}k t`
33
+ return formatMass(kg)
34
+ }